├── groceries-tab.png ├── .gitignore ├── main.cc ├── appinit.h ├── currencydelegate.cc ├── currencydelegate.h ├── app.h ├── budget-meal-planner.pro ├── appinit.cc ├── database.h ├── nametoiddelegate.h ├── LICENSE ├── nametoiddelegate.cc ├── README.md ├── database.cc ├── app.ui └── app.cc /groceries-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benjaminogles/budget-meal-planner/HEAD/groceries-tab.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ui_* 2 | moc_* 3 | *.o 4 | *.pro.user 5 | Makefile 6 | .qmake.stash 7 | budget-meal-planner 8 | -------------------------------------------------------------------------------- /main.cc: -------------------------------------------------------------------------------- 1 | 2 | #include "appinit.h" 3 | #include "app.h" 4 | 5 | int main(int argc, char **argv) 6 | { 7 | AppInit init(argc, argv); 8 | App app; 9 | app.show(); 10 | return init.exec(); 11 | } 12 | -------------------------------------------------------------------------------- /appinit.h: -------------------------------------------------------------------------------- 1 | 2 | #ifndef appinit_h 3 | #define appinit_h 4 | 5 | #include 6 | #include 7 | 8 | class AppInit : public QApplication 9 | { 10 | public: 11 | AppInit(int &argc, char **argv); 12 | ~AppInit(); 13 | }; 14 | 15 | #endif 16 | -------------------------------------------------------------------------------- /currencydelegate.cc: -------------------------------------------------------------------------------- 1 | 2 | #include "currencydelegate.h" 3 | 4 | CurrencyDelegate::CurrencyDelegate(QObject *parent) : QStyledItemDelegate(parent) 5 | { 6 | } 7 | 8 | CurrencyDelegate::~CurrencyDelegate() 9 | { 10 | } 11 | 12 | QString CurrencyDelegate::displayText(const QVariant &var, const QLocale&) const 13 | { 14 | return QString::number(var.toDouble(), 'f', 2); 15 | } 16 | -------------------------------------------------------------------------------- /currencydelegate.h: -------------------------------------------------------------------------------- 1 | 2 | #ifndef currencydelegate_h 3 | #define currencydelegate_h 4 | 5 | #include 6 | 7 | class CurrencyDelegate : public QStyledItemDelegate 8 | { 9 | public: 10 | CurrencyDelegate(QObject *parent = nullptr); 11 | ~CurrencyDelegate(); 12 | QString displayText(const QVariant&, const QLocale&) const override; 13 | }; 14 | #endif 15 | -------------------------------------------------------------------------------- /app.h: -------------------------------------------------------------------------------- 1 | 2 | #ifndef app_h 3 | #define app_h 4 | 5 | #include 6 | #include 7 | 8 | namespace Ui { class App; } 9 | 10 | class App : public QMainWindow 11 | { 12 | Q_OBJECT 13 | 14 | public: 15 | App(); 16 | ~App(); 17 | 18 | private: 19 | Ui::App *ui; 20 | struct Impl; 21 | std::unique_ptr impl; 22 | }; 23 | 24 | #endif 25 | 26 | -------------------------------------------------------------------------------- /budget-meal-planner.pro: -------------------------------------------------------------------------------- 1 | 2 | TARGET = budget-meal-planner 3 | 4 | CONFIG += c++14 5 | 6 | QT += core widgets sql 7 | 8 | SOURCES = \ 9 | main.cc \ 10 | database.cc \ 11 | appinit.cc \ 12 | app.cc \ 13 | nametoiddelegate.cc \ 14 | currencydelegate.cc 15 | 16 | HEADERS = \ 17 | database.h \ 18 | appinit.h \ 19 | app.h \ 20 | nametoiddelegate.h \ 21 | currencydelegate.h 22 | 23 | FORMS = \ 24 | app.ui 25 | 26 | -------------------------------------------------------------------------------- /appinit.cc: -------------------------------------------------------------------------------- 1 | 2 | #include "appinit.h" 3 | #include "database.h" 4 | 5 | namespace 6 | { 7 | void check_fatal(bool cond, const char *msg) 8 | { 9 | if(!cond) 10 | { 11 | qCritical("%s\n", msg); 12 | exit(-1); 13 | } 14 | } 15 | } 16 | 17 | AppInit::AppInit(int &argc, char **argv) : QApplication(argc, argv) 18 | { 19 | QString dbsrc; 20 | for (int i = 1; i < argc; i++) 21 | { 22 | QString arg(argv[i]); 23 | if (arg == "--db") 24 | { 25 | check_fatal(argc > i + 1, "Missing argument for --db option"); 26 | dbsrc = argv[i + 1]; 27 | } 28 | } 29 | check_fatal(db_init(dbsrc), "Unable to connect to or initialize database"); 30 | } 31 | 32 | AppInit::~AppInit() 33 | { 34 | } 35 | 36 | -------------------------------------------------------------------------------- /database.h: -------------------------------------------------------------------------------- 1 | 2 | #ifndef database_h 3 | #define database_h 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | bool db_init(QString); 10 | 11 | QMap db_unit_id_map(); 12 | QMap db_food_id_map(); 13 | QMap db_recipe_id_map(); 14 | 15 | QStringList db_food_names(); 16 | QStringList db_recipe_names(); 17 | 18 | int db_add_recipe(QString); 19 | int db_add_food(QString); 20 | bool db_add_planned(int); 21 | 22 | bool db_remove_id(QString, int); 23 | 24 | QString db_recipe_name(int); 25 | QString db_recipe_steps(int); 26 | 27 | int db_food_id(QString); 28 | int db_recipe_id(QString); 29 | 30 | bool db_set_recipe_name(int, QString); 31 | bool db_set_recipe_steps(int, QString); 32 | 33 | void db_clear_planned(); 34 | void db_clear_planned_groceries(); 35 | 36 | void db_generate_planned_groceries(); 37 | 38 | #endif 39 | -------------------------------------------------------------------------------- /nametoiddelegate.h: -------------------------------------------------------------------------------- 1 | 2 | #ifndef nametoiddelegate_h 3 | #define nametoiddelegate_h 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | class NameToIdDelegate : public QStyledItemDelegate 10 | { 11 | Q_OBJECT 12 | public: 13 | NameToIdDelegate(QMap name_to_id, QObject *parent = nullptr); 14 | ~NameToIdDelegate(); 15 | 16 | QWidget* createEditor(QWidget*, const QStyleOptionViewItem&, const QModelIndex&) const override; 17 | void setEditorData(QWidget*, const QModelIndex&) const override; 18 | void setModelData(QWidget*, QAbstractItemModel*, const QModelIndex&) const override; 19 | void updateEditorGeometry(QWidget*, const QStyleOptionViewItem&, const QModelIndex&) const override; 20 | QString displayText(const QVariant&, const QLocale&) const override; 21 | 22 | public slots: 23 | void reset(QMap); 24 | 25 | private: 26 | struct Impl; 27 | std::unique_ptr impl; 28 | }; 29 | #endif 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Benjamin Ogles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /nametoiddelegate.cc: -------------------------------------------------------------------------------- 1 | 2 | #include "nametoiddelegate.h" 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | struct NameToIdDelegate::Impl 9 | { 10 | QMap name_to_id; 11 | QMap id_to_name; 12 | 13 | Impl(QMap name_to_id) 14 | { 15 | reset(name_to_id); 16 | } 17 | 18 | void reset(QMap source) 19 | { 20 | name_to_id = source; 21 | id_to_name.clear(); 22 | auto i = name_to_id.constBegin(); 23 | while (i != name_to_id.constEnd()) 24 | { 25 | id_to_name.insert(i.value(), i.key()); 26 | i++; 27 | } 28 | } 29 | }; 30 | 31 | NameToIdDelegate::NameToIdDelegate(QMap name_to_id, QObject *parent) : 32 | QStyledItemDelegate(parent), 33 | impl(std::make_unique(name_to_id)) 34 | { 35 | } 36 | 37 | NameToIdDelegate::~NameToIdDelegate() 38 | { 39 | } 40 | 41 | QWidget* NameToIdDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem&, const QModelIndex&) const 42 | { 43 | QLineEdit *editor = new QLineEdit(parent); 44 | QCompleter *completer = new QCompleter(impl->name_to_id.uniqueKeys(), parent); 45 | completer->setCompletionMode(QCompleter::InlineCompletion); 46 | completer->setCaseSensitivity(Qt::CaseInsensitive); 47 | editor->setCompleter(completer); 48 | return editor; 49 | } 50 | 51 | void NameToIdDelegate::setEditorData(QWidget *wid, const QModelIndex &index) const 52 | { 53 | QLineEdit *editor = qobject_cast(wid); 54 | editor->setText(displayText(index.model()->data(index), QLocale())); 55 | } 56 | 57 | void NameToIdDelegate::setModelData(QWidget *wid, QAbstractItemModel *model, const QModelIndex &index) const 58 | { 59 | QLineEdit *editor = qobject_cast(wid); 60 | int id = -1; 61 | if (impl->name_to_id.contains(editor->text())) 62 | id = impl->name_to_id[editor->text()]; 63 | if (id < 0) 64 | model->setData(index, QVariant()); 65 | else 66 | model->setData(index, QVariant(id)); 67 | } 68 | 69 | void NameToIdDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &item, const QModelIndex&) const 70 | { 71 | editor->setGeometry(item.rect); 72 | } 73 | 74 | QString NameToIdDelegate::displayText(const QVariant &value, const QLocale&) const 75 | { 76 | if (value.isNull() || !impl->id_to_name.contains(value.toInt())) 77 | return ""; 78 | return impl->id_to_name[value.toInt()]; 79 | } 80 | 81 | 82 | void NameToIdDelegate::reset(QMap source) 83 | { 84 | impl->reset(source); 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Budget Meal Planner 3 | 4 | ![Screenshot](/groceries-tab.png) 5 | 6 | This is a simple, offline desktop app for storing recipes, creating meal plans and generating grocery lists with your budget in mind. 7 | Grocery lists are generated automatically from the meal plan. 8 | It displays the price of recipes and the grocery list according to price data you enter for each type of food. 9 | 10 | # Dependencies 11 | 12 | - C++14 13 | - Qt 5.12 14 | - SQLite 15 | 16 | # Build 17 | 18 | ``` 19 | git clone $repo_url $repo_dir 20 | cd $repo_dir 21 | qmake 22 | make 23 | ``` 24 | 25 | # Run 26 | ``` 27 | # run without --db option to use temporary in-memory database 28 | ./budget-meal-planner --db file-name-for-new-database.db 29 | ``` 30 | 31 | # Concepts 32 | 33 | Recipes reference zero or more ingredients. 34 | Ingredients reference one food and have an associated quantity. 35 | Foods can be staples or non-staples and have an associated price. 36 | Groceries reference one food and an associated quantity, often derived from ingredient quantities. 37 | The ingredient quantity is interpreted differently for staples and non-staples. 38 | 39 | A food is considered a staple if you buy it in bulk and use it for many meals. 40 | For staples, the ingredient quantity field is ignored when generating grocery lists and estimating prices. 41 | Instead, the quantity is implicitly fixed to `1`. 42 | This ensures that the list and estimated price is correct even if multiple recipes in a plan require that food. 43 | It is assumed that no sane combination of recipes would require you to purchase more of any staple then you usually do in one shopping trip. 44 | You should set the price of staples to how much it costs you to buy the food once. 45 | 46 | For non-staple foods, you should set the price to how much it costs to buy one "unit" of the food. 47 | The ingredient quantities will be summed and multiplied by this price. 48 | 49 | ## Examples 50 | 51 | A bag of rice would be a staple food. 52 | 53 | A green bell pepper would be a non-staple food. 54 | In recipes that use green bell pepper, you would want to avoid using specific units for the bell pepper ingredient. 55 | For example, using '1/2 cup bell pepper' (meaning diced) would throw off the price estimate unless `1/2 cup == 1/2 pepper`. 56 | Instead, leave the units for non-staple foods unspecified and set the quantity for the number of whole units you will buy for that recipe. 57 | 58 | Oranges may be used as a staple (bag of oranges that you eat throughout the week) or a non-staple (1 orange used in a recipe). 59 | You can create two separate foods for this purpose: 'Oranges' and 'Orange'. 60 | If you buy oranges on the same week that you plan the orange-using recipe, you will just have to remember to remove the lone orange from your grocery list. 61 | There is probably a simple way to support this use case better but it is not implemented. 62 | 63 | # Use 64 | 65 | Step by step instructions for each task. 66 | 67 | ## Meal Plan 68 | 69 | 1. Go to Groceries tab 70 | 2. Enter recipe names under Planned Recipes (names will auto complete) 71 | 72 | The grocery list will be updated along with the plan. 73 | The list can be regenerated with the corresponding button (useful after editing food or recipe data). 74 | You can clear the plan with the corresponding button. 75 | 76 | ## Add Other Groceries 77 | 78 | 1. Go to Groceries tab 79 | 2. Enter food name under Generated List (names will auto complete) 80 | 81 | Groceries added manually will be maintained separately from auto-generated groceries so they will not be removed erroneously when changing the meal plan. 82 | 83 | ## Adding Recipe 84 | 85 | 1. Go to Recipes tab 86 | 2. Click Add 87 | 3. Change title 88 | 4. Enter steps 89 | 5. Enter ingredient names 90 | 6. Change ingredient units and quantities 91 | 7. Click Done 92 | 93 | ## Editing Recipe 94 | 95 | 1. Go to Recipes tab 96 | 2. Double click on recipe 97 | 3. Click Done when finished 98 | 99 | You can also edit a recipe from the Groceries tab by double clicking a planned recipe. 100 | 101 | ## Adding Food 102 | 103 | Foods are created lazily if necessary when ingredients or groceries are added with an unrecognized name. 104 | Otherwise: 105 | 106 | 1. Go to Foods tab 107 | 2. Enter name text input 108 | 3. Hit return 109 | 4. If food is a staple, change staple field to 1 110 | 6. Enter price of purchasing this food once 111 | 112 | ## Edit Table Field 113 | 114 | 1. Double click on field or start typing while field is focused 115 | 2. Field will auto complete with valid options if applicable 116 | 117 | ## Remove Rows From Table 118 | 119 | 1. Select rows you want to remove (click each) 120 | 2. Click associated Delete button 121 | 3. Confirm 122 | 123 | -------------------------------------------------------------------------------- /database.cc: -------------------------------------------------------------------------------- 1 | 2 | #include "database.h" 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | namespace 10 | { 11 | int schema_version = 0; 12 | bool initialized = false; 13 | 14 | bool db_init_units() 15 | { 16 | QString statement; 17 | QSqlQuery query; 18 | 19 | statement = 20 | "insert into units (name) values" 21 | "('pinch')," 22 | "('teaspoon')," 23 | "('tablespoon')," 24 | "('cup')," 25 | "('quart')," 26 | "('pint')," 27 | "('gallon')," 28 | "('ounce')," 29 | "('pound');"; 30 | if (!query.exec(statement)) 31 | return false; 32 | return true; 33 | } 34 | 35 | QMap db_name_id_map(QString table) 36 | { 37 | QMap result; 38 | QSqlQuery query(QString("select name, id from %1;").arg(table)); 39 | while (query.next()) 40 | result.insert(query.value(0).toString(), query.value(1).toInt()); 41 | return result; 42 | } 43 | 44 | int db_schema_version() 45 | { 46 | QSqlQuery query("select value from schema_versions order by value asc limit 1;"); 47 | if (query.next()) 48 | return query.value(0).toInt(); 49 | return -1; 50 | } 51 | 52 | bool db_set_field_by_id(int id, QString table, QString field, QVariant value) 53 | { 54 | QSqlQuery query; 55 | if (!query.prepare(QString("update %1 set %2 = :value where id = :id;").arg(table).arg(field))) 56 | return false; 57 | query.bindValue(":value", value); 58 | query.bindValue(":id", id); 59 | return query.exec(); 60 | } 61 | 62 | int db_id_by_field(QString table, QString field, QVariant value) 63 | { 64 | QSqlQuery query; 65 | if (!query.prepare(QString("select id from %1 where %2 = :value;").arg(table).arg(field))) 66 | return -1; 67 | query.bindValue(":value", value); 68 | if (!query.exec() || !query.next()) 69 | return -1; 70 | return query.value(0).toInt(); 71 | } 72 | 73 | bool db_set_schema_version() 74 | { 75 | QSqlQuery query; 76 | if (!query.prepare("insert into schema_versions (value) values (?)")) 77 | return false; 78 | query.addBindValue(QVariant(schema_version)); 79 | return query.exec(); 80 | } 81 | 82 | QVariant db_field_by_id(QString table, QString field, int id) 83 | { 84 | QSqlQuery query; 85 | if (!query.prepare(QString("select %1 from %2 where id = :id").arg(field).arg(table))) 86 | return QVariant(); 87 | query.bindValue(":id", id); 88 | if (!query.exec() || !query.next()) 89 | return QVariant(); 90 | return query.value(0); 91 | } 92 | 93 | QStringList db_field_list(QString table, QString field) 94 | { 95 | QStringList result; 96 | QSqlQuery query(QString("select %1 from %2;").arg(field).arg(table)); 97 | while (query.next()) 98 | result.append(query.value(0).toString()); 99 | return result; 100 | } 101 | } 102 | 103 | bool db_init(QString src) 104 | { 105 | if (initialized) 106 | return false; 107 | initialized = true; 108 | 109 | QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); 110 | if (src.length()) 111 | db.setDatabaseName(src); 112 | else 113 | db.setDatabaseName(":memory:"); 114 | if (!db.open()) 115 | return false; 116 | 117 | QSqlQuery query("pragma foreign_keys = on;"); 118 | QString statement; 119 | 120 | statement = 121 | "create table if not exists schema_versions (" 122 | "id integer primary key asc," 123 | "value integer" 124 | ");"; 125 | if (!query.exec(statement)) 126 | return false; 127 | 128 | int current_version = db_schema_version(); 129 | bool fresh = current_version < 0; 130 | if (current_version < schema_version && !db_set_schema_version()) 131 | return false; 132 | 133 | statement = 134 | "create table if not exists units (" 135 | "id integer primary key asc," 136 | "name text not null," 137 | "constraint unit_name_unique unique (name)" 138 | ");"; 139 | if (!query.exec(statement)) 140 | return false; 141 | 142 | statement = 143 | "create table if not exists recipes (" 144 | "id integer primary key asc," 145 | "name text not null," 146 | "planned integer not null default 0," 147 | "meals real not null default 1," 148 | "steps text not null default ''" 149 | ");"; 150 | if (!query.exec(statement)) 151 | return false; 152 | 153 | statement = 154 | "create table if not exists foods (" 155 | "id integer primary key asc," 156 | "name text not null," 157 | "staple integer not null default 0," 158 | "price real not null default 0," 159 | "constraint food_name_unique unique (name)" 160 | ");"; 161 | if (!query.exec(statement)) 162 | return false; 163 | 164 | statement = 165 | "create table if not exists ingredients (" 166 | "id integer primary key asc," 167 | "recipe integer not null references recipes(id) on delete cascade," 168 | "food integer not null references foods(id) on delete cascade," 169 | "unit integer references units(id)," 170 | "quantity real not null default 0" 171 | ");"; 172 | if (!query.exec(statement)) 173 | return false; 174 | 175 | statement = 176 | "create table if not exists groceries (" 177 | "id integer primary key asc," 178 | "food integer not null references foods(id)," 179 | "quantity real not null," 180 | "generated integer not null default 0" 181 | ");"; 182 | if (!query.exec(statement)) 183 | return false; 184 | 185 | if (fresh && !db_init_units()) 186 | return false; 187 | 188 | return true; 189 | } 190 | 191 | QMap db_unit_id_map() 192 | { 193 | return db_name_id_map("units"); 194 | } 195 | 196 | QMap db_food_id_map() 197 | { 198 | return db_name_id_map("foods"); 199 | } 200 | 201 | QMap db_recipe_id_map() 202 | { 203 | return db_name_id_map("recipes"); 204 | } 205 | 206 | int db_add_recipe(QString name) 207 | { 208 | QSqlQuery query; 209 | if (!query.prepare("insert into recipes (name) values (:name);")) 210 | return -1; 211 | query.bindValue(":name", name); 212 | return query.exec() ? query.lastInsertId().toInt() : -1; 213 | } 214 | 215 | bool db_remove_id(QString table, int id) 216 | { 217 | QSqlQuery query; 218 | if (!query.prepare(QString("delete from %1 where id = :id;").arg(table))) 219 | return false; 220 | query.bindValue(":id", id); 221 | return query.exec(); 222 | } 223 | 224 | QString db_recipe_name(int id) 225 | { 226 | return db_field_by_id("recipes", "name", id).toString(); 227 | } 228 | 229 | QString db_recipe_steps(int id) 230 | { 231 | return db_field_by_id("recipes", "steps", id).toString(); 232 | } 233 | 234 | bool db_set_recipe_name(int id, QString name) 235 | { 236 | return db_set_field_by_id(id, "recipes", "name", name); 237 | } 238 | 239 | bool db_set_recipe_steps(int id, QString steps) 240 | { 241 | return db_set_field_by_id(id, "recipes", "steps", steps); 242 | } 243 | 244 | QStringList db_food_names() 245 | { 246 | return db_field_list("foods", "name"); 247 | } 248 | 249 | QStringList db_recipe_names() 250 | { 251 | return db_field_list("recipes", "name"); 252 | } 253 | 254 | int db_food_id(QString name) 255 | { 256 | return db_id_by_field("foods", "name", name); 257 | } 258 | 259 | int db_recipe_id(QString name) 260 | { 261 | return db_id_by_field("recipes", "name", name); 262 | } 263 | 264 | int db_add_food(QString name) 265 | { 266 | QSqlQuery query; 267 | if (!query.prepare("insert into foods (name) values (:name);")) 268 | return -1; 269 | query.bindValue(":name", name); 270 | return query.exec() ? query.lastInsertId().toInt() : -1; 271 | } 272 | 273 | void db_clear_planned_groceries() 274 | { 275 | QSqlQuery query("delete from groceries where generated = 1;"); 276 | } 277 | 278 | void db_generate_planned_groceries() 279 | { 280 | QSqlQuery query( 281 | "insert into groceries (generated, food, quantity) " 282 | "select 1, f.id, (case f.staple when 0 then sum(i.quantity) else 1 end) " 283 | "from recipes r join ingredients i on r.id = i.recipe join foods f on f.id = i.food where r.planned = 1 " 284 | "group by f.id;" 285 | ); 286 | } 287 | 288 | bool db_add_planned(int recipe) 289 | { 290 | QSqlQuery query; 291 | if (!query.prepare("update recipes set planned = 1 where id = :id")) 292 | return false; 293 | query.bindValue(":id", recipe); 294 | return query.exec(); 295 | } 296 | 297 | void db_clear_planned() 298 | { 299 | QSqlQuery query("update recipes set planned = 0;"); 300 | } 301 | -------------------------------------------------------------------------------- /app.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | App 4 | 5 | 6 | 7 | 0 8 | 0 9 | 1200 10 | 800 11 | 12 | 13 | 14 | Budget Meal Planner 15 | 16 | 17 | 18 | 19 | 20 | 21 | 0 22 | 23 | 24 | 25 | Groceries 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Planned Recipes 35 | 36 | 37 | 38 | 39 | 40 | Add: <name> 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Regenerate 54 | 55 | 56 | 57 | 58 | 59 | 60 | Clear 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Generated List 74 | 75 | 76 | 77 | 78 | 79 | Add: <name> 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | Delete 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 16777215 110 | 96 111 | 112 | 113 | 114 | Estimated Prices 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | Recipes 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | Add 139 | 140 | 141 | 142 | 143 | 144 | 145 | Delete 146 | 147 | 148 | 149 | 150 | 151 | 152 | Refresh 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | Foods 163 | 164 | 165 | 166 | 167 | 168 | Add: <name> 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | Delete 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | Recipe 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | Title 196 | 197 | 198 | 199 | 200 | 201 | 202 | Steps 203 | 204 | 205 | 206 | 207 | 208 | 209 | Done 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | Add: <name> 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | Delete 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 0 249 | 0 250 | 1200 251 | 22 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /app.cc: -------------------------------------------------------------------------------- 1 | 2 | #include "app.h" 3 | #include "ui_app.h" 4 | #include "database.h" 5 | #include "nametoiddelegate.h" 6 | #include "currencydelegate.h" 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | 18 | namespace 19 | { 20 | const int groceries_tab_idx = 0; 21 | const int recipe_tab_idx = 3; 22 | 23 | bool confirmed(QWidget *parent, QString description) 24 | { 25 | QMessageBox::StandardButton reply; 26 | reply = QMessageBox::question(parent, description, "Are you sure?", QMessageBox::Yes|QMessageBox::No); 27 | return reply == QMessageBox::Yes; 28 | } 29 | 30 | QList table_remove_rows(QItemSelectionModel *select, QSqlTableModel *model, int id_column) 31 | { 32 | QList removed; 33 | if (!select->hasSelection()) 34 | return removed; 35 | auto indexes = select->selectedRows(id_column); 36 | for (auto index : indexes) 37 | { 38 | if (model->removeRows(index.row(), 1)) 39 | removed.append(index.data().toInt()); 40 | } 41 | return removed; 42 | } 43 | 44 | void query_refresh(QSqlQueryModel *model) 45 | { 46 | QString str = model->query().executedQuery(); 47 | model->query().clear(); 48 | model->setQuery(str); 49 | } 50 | 51 | QList query_remove_ids(QItemSelectionModel *select, QString table, int id_column) 52 | { 53 | QList removed; 54 | if (!select->hasSelection()) 55 | return removed; 56 | auto indexes = select->selectedRows(id_column); 57 | for (auto index : indexes) 58 | { 59 | QVariant var = index.data(); 60 | if (var.isNull()) 61 | continue; 62 | if (db_remove_id(table, var.toInt())) 63 | removed.append(var.toInt()); 64 | } 65 | return removed; 66 | } 67 | } 68 | 69 | struct App::Impl 70 | { 71 | App *app; 72 | QSqlQueryModel *planned; 73 | QSqlTableModel *groceries; 74 | QSqlQueryModel *recipes; 75 | QSqlTableModel *foods; 76 | QSqlTableModel *ingredients; 77 | QSqlQueryModel *estimates; 78 | CurrencyDelegate *currency_delegate; 79 | QCompleter *food_completer = nullptr; 80 | QCompleter *recipe_completer = nullptr; 81 | int recipe_id = -1; 82 | 83 | Impl(App *app_) : 84 | app(app_), 85 | planned(new QSqlQueryModel(app)), 86 | groceries(new QSqlTableModel(app)), 87 | recipes(new QSqlQueryModel(app)), 88 | foods(new QSqlTableModel(app)), 89 | ingredients(new QSqlTableModel(app)), 90 | estimates(new QSqlQueryModel(app)), 91 | currency_delegate(new CurrencyDelegate(app)) 92 | { 93 | planned->setQuery("select id, name from recipes where planned != 0"); 94 | 95 | groceries->setEditStrategy(QSqlTableModel::OnFieldChange); 96 | groceries->setTable("groceries"); 97 | groceries->select(); 98 | 99 | recipes->setQuery( 100 | "select" 101 | " r.id," 102 | " r.name," 103 | " sum(case f.staple when 1 then f.price else 0 end) as staples, " 104 | " sum(case f.staple when 0 then i.quantity * f.price else 0 end) as fresh " 105 | "from recipes r" 106 | " left outer join ingredients i on r.id = i.recipe" 107 | " left outer join foods f on f.id = i.food " 108 | "group by r.id" 109 | ); 110 | 111 | foods->setEditStrategy(QSqlTableModel::OnFieldChange); 112 | foods->setTable("foods"); 113 | foods->select(); 114 | 115 | ingredients->setEditStrategy(QSqlTableModel::OnFieldChange); 116 | ingredients->setTable("ingredients"); 117 | ingredients->setFilter("recipe is null"); 118 | 119 | groceries->setEditStrategy(QSqlTableModel::OnFieldChange); 120 | groceries->setTable("groceries"); 121 | groceries->select(); 122 | 123 | estimates->setQuery( 124 | "select" 125 | " sum(g.quantity * f.price) as total," 126 | " sum(case f.staple when 1 then (g.quantity * f.price) else 0 end) as staples, " 127 | " sum(case f.staple when 0 then (g.quantity * f.price) else 0 end) as fresh " 128 | "from groceries g join foods f on f.id = g.food" 129 | ); 130 | } 131 | 132 | ~Impl() 133 | { 134 | if (food_completer) 135 | delete food_completer; 136 | if (recipe_completer) 137 | delete recipe_completer; 138 | } 139 | 140 | void reset_recipe_completer() 141 | { 142 | auto old = recipe_completer; 143 | recipe_completer = new QCompleter(db_recipe_names()); 144 | recipe_completer->setCompletionMode(QCompleter::InlineCompletion); 145 | recipe_completer->setCaseSensitivity(Qt::CaseInsensitive); 146 | app->ui->lePlanned->setCompleter(recipe_completer); 147 | if (old) 148 | delete old; 149 | auto delegate = qobject_cast(app->ui->plannedView->itemDelegate()); 150 | delegate->reset(db_recipe_id_map()); 151 | } 152 | 153 | void reset_recipe_tab() 154 | { 155 | recipe_id = -1; 156 | ingredients->setFilter("recipe is null"); 157 | ingredients->select(); 158 | app->ui->leRecipeTitle->clear(); 159 | app->ui->teRecipeSteps->clear(); 160 | app->ui->recipeTab->setEnabled(false); 161 | } 162 | 163 | void start_edit_recipe(int id) 164 | { 165 | if (recipe_id >= 0) 166 | reset_recipe_tab(); 167 | ingredients->setFilter(QString("recipe = %1").arg(id)); 168 | app->ui->leRecipeTitle->setText(db_recipe_name(id)); 169 | app->ui->teRecipeSteps->setPlainText(db_recipe_steps(id)); 170 | app->ui->recipeTab->setEnabled(true); 171 | app->ui->tabs->setCurrentIndex(recipe_tab_idx); 172 | recipe_id = id; 173 | } 174 | 175 | void start_add_recipe(QString name) 176 | { 177 | int id = db_add_recipe(name); 178 | if (id >= 0) 179 | { 180 | start_edit_recipe(id); 181 | reset_recipe_completer(); 182 | } 183 | } 184 | 185 | void stop_edit_recipe() 186 | { 187 | if (recipe_id < 0) 188 | return; 189 | QString name = app->ui->leRecipeTitle->text(); 190 | QString steps = app->ui->teRecipeSteps->toPlainText(); 191 | db_set_recipe_name(recipe_id, name); 192 | db_set_recipe_steps(recipe_id, steps); 193 | reset_recipe_tab(); 194 | query_refresh(recipes); 195 | reset_recipe_completer(); 196 | app->ui->tabs->setCurrentIndex(groceries_tab_idx); 197 | } 198 | 199 | void remove_selected_recipes() 200 | { 201 | auto select = app->ui->recipesView->selectionModel(); 202 | QList removed = query_remove_ids(select, "recipes", 0); 203 | if (removed.contains(recipe_id)) 204 | reset_recipe_tab(); 205 | if (removed.size() > 0) 206 | { 207 | query_refresh(recipes); 208 | reset_recipe_completer(); 209 | } 210 | } 211 | 212 | void reset_food_completer() 213 | { 214 | auto old = food_completer; 215 | food_completer = new QCompleter(db_food_names()); 216 | food_completer->setCompletionMode(QCompleter::InlineCompletion); 217 | food_completer->setCaseSensitivity(Qt::CaseInsensitive); 218 | app->ui->leIngredient->setCompleter(food_completer); 219 | app->ui->leGrocery->setCompleter(food_completer); 220 | if (old) 221 | delete old; 222 | auto delegate = qobject_cast(app->ui->ingredientsView->itemDelegateForColumn(2)); 223 | delegate->reset(db_food_id_map()); 224 | delegate = qobject_cast(app->ui->groceriesView->itemDelegateForColumn(1)); 225 | delegate->reset(db_food_id_map()); 226 | } 227 | 228 | bool add_food(QString name) 229 | { 230 | QSqlRecord record; 231 | record.append(QSqlField("name", QVariant::String)); 232 | record.setValue("name", name); 233 | if (foods->insertRecord(-1, record)) 234 | { 235 | reset_food_completer(); 236 | return true; 237 | } 238 | return false; 239 | } 240 | 241 | void remove_selected_foods() 242 | { 243 | QList removed = table_remove_rows(app->ui->foodsView->selectionModel(), foods, 0); 244 | if (removed.size() > 0) 245 | { 246 | foods->select(); 247 | reset_food_completer(); 248 | } 249 | } 250 | 251 | bool add_ingredient(int recipe, QString name) 252 | { 253 | int food_id = db_food_id(name); 254 | if (food_id < 0 && !name.isEmpty()) 255 | { 256 | if (!add_food(name)) 257 | return false; 258 | food_id = db_food_id(name); 259 | if (food_id < 0) 260 | return false; 261 | } 262 | 263 | QSqlRecord record; 264 | record.append(QSqlField("recipe", QVariant::Int)); 265 | record.append(QSqlField("food", QVariant::Int)); 266 | record.setValue("recipe", recipe); 267 | record.setValue("food", food_id); 268 | return ingredients->insertRecord(-1, record); 269 | } 270 | 271 | void remove_selected_ingredients() 272 | { 273 | QList removed = table_remove_rows(app->ui->ingredientsView->selectionModel(), ingredients, 0); 274 | if (removed.size() > 0) 275 | ingredients->select(); 276 | } 277 | 278 | bool add_grocery(QString name) 279 | { 280 | int food_id = db_food_id(name); 281 | if (food_id < 0) 282 | { 283 | if (!add_food(name)) 284 | return false; 285 | food_id = db_food_id(name); 286 | if (food_id < 0) 287 | return false; 288 | } 289 | 290 | QSqlRecord record; 291 | record.append(QSqlField("food", QVariant::Int)); 292 | record.append(QSqlField("quantity", QVariant::Double)); 293 | record.setValue("food", food_id); 294 | record.setValue("quantity", 1.0); 295 | if( groceries->insertRecord(-1, record)) 296 | { 297 | query_refresh(estimates); 298 | return true; 299 | } 300 | return false; 301 | } 302 | 303 | void remove_selected_groceries() 304 | { 305 | QList removed = table_remove_rows(app->ui->groceriesView->selectionModel(), groceries, 0); 306 | if (removed.size() > 0) 307 | { 308 | groceries->select(); 309 | query_refresh(estimates); 310 | } 311 | } 312 | 313 | void regenerate_planned_groceries() 314 | { 315 | db_clear_planned_groceries(); 316 | db_generate_planned_groceries(); 317 | groceries->select(); 318 | query_refresh(estimates); 319 | } 320 | 321 | bool add_planned(QString name) 322 | { 323 | int recipe_id = db_recipe_id(name); 324 | if (recipe_id < 0) 325 | return false; 326 | if (db_add_planned(recipe_id)) 327 | { 328 | query_refresh(planned); 329 | regenerate_planned_groceries(); 330 | return true; 331 | } 332 | return false; 333 | } 334 | 335 | void clear_planned() 336 | { 337 | db_clear_planned_groceries(); 338 | groceries->select(); 339 | db_clear_planned(); 340 | query_refresh(planned); 341 | query_refresh(estimates); 342 | } 343 | }; 344 | 345 | App::App() : ui(new Ui::App), impl(std::make_unique(this)) 346 | { 347 | ui->setupUi(this); 348 | 349 | ui->plannedView->setModel(impl->planned); 350 | ui->plannedView->setSelectionMode(QAbstractItemView::NoSelection); 351 | ui->plannedView->setItemDelegate(new NameToIdDelegate(db_recipe_id_map(), this)); 352 | 353 | ui->groceriesView->setModel(impl->groceries); 354 | ui->groceriesView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); 355 | ui->groceriesView->setSelectionMode(QAbstractItemView::MultiSelection); 356 | ui->groceriesView->setSelectionBehavior(QAbstractItemView::SelectRows); 357 | ui->groceriesView->setItemDelegateForColumn(1, new NameToIdDelegate(db_food_id_map(), this)); 358 | 359 | ui->recipesView->setModel(impl->recipes); 360 | ui->recipesView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); 361 | ui->recipesView->setSelectionMode(QAbstractItemView::MultiSelection); 362 | ui->recipesView->setSelectionBehavior(QAbstractItemView::SelectRows); 363 | ui->recipesView->setItemDelegateForColumn(3, impl->currency_delegate); 364 | ui->recipesView->setItemDelegateForColumn(4, impl->currency_delegate); 365 | ui->recipesView->setItemDelegateForColumn(5, impl->currency_delegate); 366 | 367 | ui->foodsView->setModel(impl->foods); 368 | ui->foodsView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); 369 | ui->foodsView->setSelectionMode(QAbstractItemView::MultiSelection); 370 | ui->foodsView->setSelectionBehavior(QAbstractItemView::SelectRows); 371 | ui->foodsView->setItemDelegateForColumn(3, impl->currency_delegate); 372 | 373 | ui->ingredientsView->setModel(impl->ingredients); 374 | ui->ingredientsView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); 375 | ui->ingredientsView->setSelectionMode(QAbstractItemView::MultiSelection); 376 | ui->ingredientsView->setSelectionBehavior(QAbstractItemView::SelectRows); 377 | ui->ingredientsView->setItemDelegateForColumn(2, new NameToIdDelegate(db_food_id_map(), this)); 378 | ui->ingredientsView->setItemDelegateForColumn(3, new NameToIdDelegate(db_unit_id_map(), this)); 379 | 380 | ui->estimatesView->setModel(impl->estimates); 381 | ui->estimatesView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); 382 | ui->estimatesView->setSelectionMode(QAbstractItemView::NoSelection); 383 | ui->estimatesView->setItemDelegateForColumn(0, impl->currency_delegate); 384 | ui->estimatesView->setItemDelegateForColumn(1, impl->currency_delegate); 385 | ui->estimatesView->setItemDelegateForColumn(2, impl->currency_delegate); 386 | 387 | #ifdef QT_NO_DEBUG 388 | ui->recipesView->hideColumn(0); 389 | ui->groceriesView->hideColumn(0); 390 | ui->groceriesView->hideColumn(3); 391 | ui->foodsView->hideColumn(0); 392 | ui->ingredientsView->hideColumn(0); 393 | ui->ingredientsView->hideColumn(1); 394 | #endif 395 | 396 | ui->tabs->setCurrentIndex(groceries_tab_idx); 397 | ui->recipeTab->setEnabled(false); 398 | 399 | impl->reset_food_completer(); 400 | impl->reset_recipe_completer(); 401 | 402 | connect(ui->bAddRecipe, &QPushButton::released, this, [this]() 403 | { 404 | if (impl->recipe_id < 0 || confirmed(this, "Add New Recipe (Abandon Current Edit)")) 405 | impl->start_add_recipe("Untitled"); 406 | }); 407 | 408 | connect(ui->bDeleteRecipe, &QPushButton::released, this, [this]() 409 | { 410 | if (confirmed(this, "Delete Selected Recipes")) 411 | impl->remove_selected_recipes(); 412 | }); 413 | 414 | connect(ui->bDoneRecipe, &QPushButton::released, this, [this]() 415 | { 416 | impl->stop_edit_recipe(); 417 | }); 418 | 419 | connect(ui->leFood, &QLineEdit::returnPressed, this, [this]() 420 | { 421 | if (impl->add_food(ui->leFood->text())) 422 | ui->leFood->clear(); 423 | }); 424 | 425 | connect(ui->bDeleteFood, &QPushButton::released, this, [this]() 426 | { 427 | if (confirmed(this, "Delete Selected Foods")) 428 | impl->remove_selected_foods(); 429 | }); 430 | 431 | connect(ui->recipesView, &QTableView::doubleClicked, this, [this](const QModelIndex &index) 432 | { 433 | if (impl->recipe_id < 0 || confirmed(this, "Edit Recipe (Abandon Current Edit)")) 434 | impl->start_edit_recipe(index.siblingAtColumn(0).data().toInt()); 435 | }); 436 | 437 | connect(ui->leIngredient, &QLineEdit::returnPressed, this, [this]() 438 | { 439 | if (impl->add_ingredient(impl->recipe_id, ui->leIngredient->text())) 440 | ui->leIngredient->clear(); 441 | }); 442 | 443 | connect(ui->bDeleteIngredient, &QPushButton::released, this, [this]() 444 | { 445 | if (confirmed(this, "Remove Selected Ingredients")) 446 | impl->remove_selected_ingredients(); 447 | }); 448 | 449 | connect(ui->leGrocery, &QLineEdit::returnPressed, this, [this]() 450 | { 451 | if (impl->add_grocery(ui->leGrocery->text())) 452 | ui->leGrocery->clear(); 453 | }); 454 | 455 | connect(ui->lePlanned, &QLineEdit::returnPressed, this, [this]() 456 | { 457 | if (impl->add_planned(ui->lePlanned->text())) 458 | ui->lePlanned->clear(); 459 | }); 460 | 461 | connect(ui->bClearPlanned, &QPushButton::released, this, [this]() 462 | { 463 | if (confirmed(this, "Un-Plan All")) 464 | impl->clear_planned(); 465 | }); 466 | 467 | connect(ui->bRegeneratePlanned, &QPushButton::released, this, [this]() 468 | { 469 | impl->regenerate_planned_groceries(); 470 | }); 471 | 472 | connect(ui->bDeleteGrocery, &QPushButton::released, this, [this]() 473 | { 474 | if (confirmed(this, "Remove Selected Groceries")) 475 | impl->remove_selected_groceries(); 476 | }); 477 | 478 | connect(ui->plannedView, &QListView::doubleClicked, this, [this](const QModelIndex &index) 479 | { 480 | if (impl->recipe_id < 0 || confirmed(this, "Edit Recipe (Abandon Current Edit)")) 481 | impl->start_edit_recipe(index.siblingAtColumn(0).data().toInt()); 482 | }); 483 | 484 | connect(ui->bRefreshRecipes, &QPushButton::released, this, [this]() 485 | { 486 | query_refresh(impl->recipes); 487 | }); 488 | } 489 | 490 | App::~App() 491 | { 492 | delete ui; 493 | } 494 | 495 | --------------------------------------------------------------------------------