From 6587779a58a343fd00605963f6b10ecf47860d90 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Sat, 11 Jul 2026 17:47:40 +0200 Subject: [PATCH] init --- .gitignore | 173 ++ .metadata | 36 + README.md | 17 + analysis_options.yaml | 28 + android/.gitignore | 14 + android/app/build.gradle.kts | 45 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 45 + .../dev/brammie15/kooltab2/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 24 + android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle.kts | 26 + devtools_options.yaml | 5 + lib/app/app.dart | 23 + lib/app/app_bootstrap.dart | 36 + lib/app/router.dart | 33 + lib/database/app_database.dart | 112 + lib/database/app_database.g.dart | 2513 +++++++++++++++++ lib/main.dart | 54 + lib/models/bar_tab.dart | 39 + lib/models/product.dart | 51 + lib/models/tab_item.dart | 27 + lib/services/bar_tab_service.dart | 214 ++ lib/services/product_service.dart | 121 + lib/theme.dart | 327 +++ lib/viewmodels/bar_screen_view_model.dart | 140 + lib/viewmodels/product_list_view_model.dart | 107 + lib/views/bar_screen_view.dart | 578 ++++ lib/views/product_form_view.dart | 451 +++ lib/views/product_list_view.dart | 87 + pubspec.lock | 666 +++++ pubspec.yaml | 98 + test/widget_test.dart | 14 + web/favicon.png | Bin 0 -> 917 bytes web/icons/Icon-192.png | Bin 0 -> 5292 bytes web/icons/Icon-512.png | Bin 0 -> 8252 bytes web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes web/index.html | 46 + web/manifest.json | 35 + windows/.gitignore | 17 + windows/CMakeLists.txt | 108 + windows/flutter/CMakeLists.txt | 109 + windows/runner/CMakeLists.txt | 40 + windows/runner/Runner.rc | 121 + windows/runner/flutter_window.cpp | 71 + windows/runner/flutter_window.h | 33 + windows/runner/main.cpp | 43 + windows/runner/resource.h | 16 + windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes windows/runner/runner.exe.manifest | 14 + windows/runner/utils.cpp | 69 + windows/runner/utils.h | 19 + windows/runner/win32_window.cpp | 288 ++ windows/runner/win32_window.h | 102 + 66 files changed, 7325 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/dev/brammie15/kooltab2/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 devtools_options.yaml create mode 100644 lib/app/app.dart create mode 100644 lib/app/app_bootstrap.dart create mode 100644 lib/app/router.dart create mode 100644 lib/database/app_database.dart create mode 100644 lib/database/app_database.g.dart create mode 100644 lib/main.dart create mode 100644 lib/models/bar_tab.dart create mode 100644 lib/models/product.dart create mode 100644 lib/models/tab_item.dart create mode 100644 lib/services/bar_tab_service.dart create mode 100644 lib/services/product_service.dart create mode 100644 lib/theme.dart create mode 100644 lib/viewmodels/bar_screen_view_model.dart create mode 100644 lib/viewmodels/product_list_view_model.dart create mode 100644 lib/views/bar_screen_view.dart create mode 100644 lib/views/product_form_view.dart create mode 100644 lib/views/product_list_view.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart create mode 100644 web/favicon.png create mode 100644 web/icons/Icon-192.png create mode 100644 web/icons/Icon-512.png create mode 100644 web/icons/Icon-maskable-192.png create mode 100644 web/icons/Icon-maskable-512.png create mode 100644 web/index.html create mode 100644 web/manifest.json create mode 100644 windows/.gitignore create mode 100644 windows/CMakeLists.txt create mode 100644 windows/flutter/CMakeLists.txt create mode 100644 windows/runner/CMakeLists.txt create mode 100644 windows/runner/Runner.rc create mode 100644 windows/runner/flutter_window.cpp create mode 100644 windows/runner/flutter_window.h create mode 100644 windows/runner/main.cpp create mode 100644 windows/runner/resource.h create mode 100644 windows/runner/resources/app_icon.ico create mode 100644 windows/runner/runner.exe.manifest create mode 100644 windows/runner/utils.cpp create mode 100644 windows/runner/utils.h create mode 100644 windows/runner/win32_window.cpp create mode 100644 windows/runner/win32_window.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..209a70a --- /dev/null +++ b/.gitignore @@ -0,0 +1,173 @@ +# Do not remove or rename entries in this file, only add new ones +# See https://github.com/flutter/flutter/issues/128635 for more context. + +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# As packages are no longer pinned, we use a lockfile for testing locally. +# When unpinning packages, Using lockfiles ensures that failures in PRs are +# actually due to those PRs, not due to a package being updated. +!/pubspec.lock + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/* +.ccls-cache + +# This file, on the master branch, should never exist or be checked-in. +# +# On a *final* release branch, that is, what will ship to stable or beta, the +# file can be force added (git add --force) and checked-in in order to effectively +# "pin" the engine artifact version so the flutter tool does not need to use git +# to determine the engine artifacts. +# +# See https://github.com/flutter/flutter/blob/main/docs/tool/Engine-artifacts.md. +/bin/internal/engine.version + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/internal/engine.realm +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/api_docs.zip +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-preload-cache/ +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks +local.properties +**/.cxx/ + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Swift package manager (for code indexing) +**/.build/ + +# Windows +**/windows/flutter/ephemeral/ +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/ephemeral/ +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!.vscode/settings.json + +# Monorepo +.cipd +.gclient +.gclient_entries +.python-version +.gclient_previous_custom_vars +.gclient_previous_sync_commits + +# AI related +.agents/rules/personal-* +.agents/skills/personal-* +/.agent-shell/ \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..e1ce952 --- /dev/null +++ b/.metadata @@ -0,0 +1,36 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: web + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: windows + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c29748 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# kooltab2 + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..ffb5c79 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "dev.brammie15.kooltab2" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "dev.brammie15.kooltab2" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5f6e15a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/dev/brammie15/kooltab2/MainActivity.kt b/android/app/src/main/kotlin/dev/brammie15/kooltab2/MainActivity.kt new file mode 100644 index 0000000..caa79b2 --- /dev/null +++ b/android/app/src/main/kotlin/dev/brammie15/kooltab2/MainActivity.kt @@ -0,0 +1,5 @@ +package dev.brammie15.kooltab2 + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..7ab2426 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,5 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - drift: true + - provider: true \ No newline at end of file diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..2c209d9 --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:kooltab2/theme.dart'; + +import 'router.dart'; + +class KoolTabApp extends StatelessWidget { + const KoolTabApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'KoolTab', + debugShowCheckedModeBanner: false, + routerConfig: appRouter, + // theme: ThemeData( + // useMaterial3: false, + // colorSchemeSeed: Colors.redAccent, + // brightness: Brightness.dark, + // ), + theme: neoBrutalDarkTheme + ); + } +} \ No newline at end of file diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart new file mode 100644 index 0000000..a12213f --- /dev/null +++ b/lib/app/app_bootstrap.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../viewmodels/bar_screen_view_model.dart'; +import '../viewmodels/product_list_view_model.dart'; + +class AppBootstrap extends StatefulWidget { + final Widget child; + + const AppBootstrap({ + super.key, + required this.child, + }); + + @override + State createState() => _AppBootstrapState(); +} + +class _AppBootstrapState extends State { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + context.read().ensureLoaded(); + context.read().ensureLoaded(); + }); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} \ No newline at end of file diff --git a/lib/app/router.dart b/lib/app/router.dart new file mode 100644 index 0000000..ab56c8c --- /dev/null +++ b/lib/app/router.dart @@ -0,0 +1,33 @@ +import 'package:go_router/go_router.dart'; + +import '../views/bar_screen_view.dart'; +import '../views/product_form_view.dart'; +import '../views/product_list_view.dart'; + +final appRouter = GoRouter( + initialLocation: '/bar', + routes: [ + GoRoute( + path: '/bar', + builder: (context, state) => const BarScreenView(), + ), + GoRoute( + path: '/products', + builder: (context, state) => const ProductListView(), + routes: [ + GoRoute( + path: 'new', + builder: (context, state) => const ProductFormView(), + ), + GoRoute( + path: ':id/edit', + builder: (context, state) { + final productId = state.pathParameters['id']!; + + return ProductFormView(productId: productId); + }, + ), + ], + ), + ], +); \ No newline at end of file diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart new file mode 100644 index 0000000..b04ac35 --- /dev/null +++ b/lib/database/app_database.dart @@ -0,0 +1,112 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:path_provider/path_provider.dart'; + +part 'app_database.g.dart'; + +@DataClassName('ProductRow') +class Products extends Table { + TextColumn get id => text()(); + + TextColumn get name => text()(); + + TextColumn get category => text()(); + + IntColumn get stockQuantity => integer()(); + + IntColumn get lowStockThreshold => integer()(); + + IntColumn get priceInCents => integer()(); + + TextColumn get imagePath => text().nullable()(); + + BoolColumn get active => boolean().withDefault(const Constant(true))(); + + @override + Set get primaryKey => {id}; +} + +@DataClassName('BarTabRow') +class BarTabs extends Table { + TextColumn get id => text()(); + + TextColumn get customerName => text()(); + + TextColumn get status => text().withDefault(const Constant('open'))(); + + DateTimeColumn get openedAt => dateTime()(); + + DateTimeColumn get closedAt => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; +} + +@DataClassName('TabItemRow') +class TabItems extends Table { + TextColumn get id => text()(); + + TextColumn get tabId => text().references( + BarTabs, + #id, + onDelete: KeyAction.cascade, + )(); + + TextColumn get productId => text().references( + Products, + #id, + )(); + + TextColumn get productName => text()(); + + IntColumn get quantity => integer()(); + + IntColumn get unitPriceInCents => integer()(); + + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} + +@DriftDatabase( + tables: [ + Products, + BarTabs, + TabItems, + ], +) +class AppDatabase extends _$AppDatabase { + AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); + + @override + int get schemaVersion => 3; + + @override + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (migrator) async { + await migrator.createAll(); + }, + onUpgrade: (migrator, from, to) async { + if (from < 2) { + await migrator.createTable(barTabs); + await migrator.createTable(tabItems); + } + + if (from < 3) { + await migrator.addColumn(products, products.imagePath); + } + }, + ); + } + + static QueryExecutor _openConnection() { + return driftDatabase( + name: 'kooltab', + native: const DriftNativeOptions( + databaseDirectory: getApplicationSupportDirectory, + ), + ); + } +} \ No newline at end of file diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart new file mode 100644 index 0000000..8d65e26 --- /dev/null +++ b/lib/database/app_database.g.dart @@ -0,0 +1,2513 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $ProductsTable extends Products + with TableInfo<$ProductsTable, ProductRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProductsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _categoryMeta = const VerificationMeta( + 'category', + ); + @override + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _stockQuantityMeta = const VerificationMeta( + 'stockQuantity', + ); + @override + late final GeneratedColumn stockQuantity = GeneratedColumn( + 'stock_quantity', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _lowStockThresholdMeta = const VerificationMeta( + 'lowStockThreshold', + ); + @override + late final GeneratedColumn lowStockThreshold = GeneratedColumn( + 'low_stock_threshold', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _priceInCentsMeta = const VerificationMeta( + 'priceInCents', + ); + @override + late final GeneratedColumn priceInCents = GeneratedColumn( + 'price_in_cents', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _imagePathMeta = const VerificationMeta( + 'imagePath', + ); + @override + late final GeneratedColumn imagePath = GeneratedColumn( + 'image_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _activeMeta = const VerificationMeta('active'); + @override + late final GeneratedColumn active = GeneratedColumn( + 'active', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("active" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); + @override + List get $columns => [ + id, + name, + category, + stockQuantity, + lowStockThreshold, + priceInCents, + imagePath, + active, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'products'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('category')) { + context.handle( + _categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta), + ); + } else if (isInserting) { + context.missing(_categoryMeta); + } + if (data.containsKey('stock_quantity')) { + context.handle( + _stockQuantityMeta, + stockQuantity.isAcceptableOrUnknown( + data['stock_quantity']!, + _stockQuantityMeta, + ), + ); + } else if (isInserting) { + context.missing(_stockQuantityMeta); + } + if (data.containsKey('low_stock_threshold')) { + context.handle( + _lowStockThresholdMeta, + lowStockThreshold.isAcceptableOrUnknown( + data['low_stock_threshold']!, + _lowStockThresholdMeta, + ), + ); + } else if (isInserting) { + context.missing(_lowStockThresholdMeta); + } + if (data.containsKey('price_in_cents')) { + context.handle( + _priceInCentsMeta, + priceInCents.isAcceptableOrUnknown( + data['price_in_cents']!, + _priceInCentsMeta, + ), + ); + } else if (isInserting) { + context.missing(_priceInCentsMeta); + } + if (data.containsKey('image_path')) { + context.handle( + _imagePathMeta, + imagePath.isAcceptableOrUnknown(data['image_path']!, _imagePathMeta), + ); + } + if (data.containsKey('active')) { + context.handle( + _activeMeta, + active.isAcceptableOrUnknown(data['active']!, _activeMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ProductRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ProductRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + )!, + stockQuantity: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}stock_quantity'], + )!, + lowStockThreshold: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}low_stock_threshold'], + )!, + priceInCents: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}price_in_cents'], + )!, + imagePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}image_path'], + ), + active: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}active'], + )!, + ); + } + + @override + $ProductsTable createAlias(String alias) { + return $ProductsTable(attachedDatabase, alias); + } +} + +class ProductRow extends DataClass implements Insertable { + final String id; + final String name; + final String category; + final int stockQuantity; + final int lowStockThreshold; + final int priceInCents; + final String? imagePath; + final bool active; + const ProductRow({ + required this.id, + required this.name, + required this.category, + required this.stockQuantity, + required this.lowStockThreshold, + required this.priceInCents, + this.imagePath, + required this.active, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['category'] = Variable(category); + map['stock_quantity'] = Variable(stockQuantity); + map['low_stock_threshold'] = Variable(lowStockThreshold); + map['price_in_cents'] = Variable(priceInCents); + if (!nullToAbsent || imagePath != null) { + map['image_path'] = Variable(imagePath); + } + map['active'] = Variable(active); + return map; + } + + ProductsCompanion toCompanion(bool nullToAbsent) { + return ProductsCompanion( + id: Value(id), + name: Value(name), + category: Value(category), + stockQuantity: Value(stockQuantity), + lowStockThreshold: Value(lowStockThreshold), + priceInCents: Value(priceInCents), + imagePath: imagePath == null && nullToAbsent + ? const Value.absent() + : Value(imagePath), + active: Value(active), + ); + } + + factory ProductRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ProductRow( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + category: serializer.fromJson(json['category']), + stockQuantity: serializer.fromJson(json['stockQuantity']), + lowStockThreshold: serializer.fromJson(json['lowStockThreshold']), + priceInCents: serializer.fromJson(json['priceInCents']), + imagePath: serializer.fromJson(json['imagePath']), + active: serializer.fromJson(json['active']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'category': serializer.toJson(category), + 'stockQuantity': serializer.toJson(stockQuantity), + 'lowStockThreshold': serializer.toJson(lowStockThreshold), + 'priceInCents': serializer.toJson(priceInCents), + 'imagePath': serializer.toJson(imagePath), + 'active': serializer.toJson(active), + }; + } + + ProductRow copyWith({ + String? id, + String? name, + String? category, + int? stockQuantity, + int? lowStockThreshold, + int? priceInCents, + Value imagePath = const Value.absent(), + bool? active, + }) => ProductRow( + id: id ?? this.id, + name: name ?? this.name, + category: category ?? this.category, + stockQuantity: stockQuantity ?? this.stockQuantity, + lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, + priceInCents: priceInCents ?? this.priceInCents, + imagePath: imagePath.present ? imagePath.value : this.imagePath, + active: active ?? this.active, + ); + ProductRow copyWithCompanion(ProductsCompanion data) { + return ProductRow( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + category: data.category.present ? data.category.value : this.category, + stockQuantity: data.stockQuantity.present + ? data.stockQuantity.value + : this.stockQuantity, + lowStockThreshold: data.lowStockThreshold.present + ? data.lowStockThreshold.value + : this.lowStockThreshold, + priceInCents: data.priceInCents.present + ? data.priceInCents.value + : this.priceInCents, + imagePath: data.imagePath.present ? data.imagePath.value : this.imagePath, + active: data.active.present ? data.active.value : this.active, + ); + } + + @override + String toString() { + return (StringBuffer('ProductRow(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('category: $category, ') + ..write('stockQuantity: $stockQuantity, ') + ..write('lowStockThreshold: $lowStockThreshold, ') + ..write('priceInCents: $priceInCents, ') + ..write('imagePath: $imagePath, ') + ..write('active: $active') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + category, + stockQuantity, + lowStockThreshold, + priceInCents, + imagePath, + active, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ProductRow && + other.id == this.id && + other.name == this.name && + other.category == this.category && + other.stockQuantity == this.stockQuantity && + other.lowStockThreshold == this.lowStockThreshold && + other.priceInCents == this.priceInCents && + other.imagePath == this.imagePath && + other.active == this.active); +} + +class ProductsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value category; + final Value stockQuantity; + final Value lowStockThreshold; + final Value priceInCents; + final Value imagePath; + final Value active; + final Value rowid; + const ProductsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.category = const Value.absent(), + this.stockQuantity = const Value.absent(), + this.lowStockThreshold = const Value.absent(), + this.priceInCents = const Value.absent(), + this.imagePath = const Value.absent(), + this.active = const Value.absent(), + this.rowid = const Value.absent(), + }); + ProductsCompanion.insert({ + required String id, + required String name, + required String category, + required int stockQuantity, + required int lowStockThreshold, + required int priceInCents, + this.imagePath = const Value.absent(), + this.active = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + category = Value(category), + stockQuantity = Value(stockQuantity), + lowStockThreshold = Value(lowStockThreshold), + priceInCents = Value(priceInCents); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? category, + Expression? stockQuantity, + Expression? lowStockThreshold, + Expression? priceInCents, + Expression? imagePath, + Expression? active, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (category != null) 'category': category, + if (stockQuantity != null) 'stock_quantity': stockQuantity, + if (lowStockThreshold != null) 'low_stock_threshold': lowStockThreshold, + if (priceInCents != null) 'price_in_cents': priceInCents, + if (imagePath != null) 'image_path': imagePath, + if (active != null) 'active': active, + if (rowid != null) 'rowid': rowid, + }); + } + + ProductsCompanion copyWith({ + Value? id, + Value? name, + Value? category, + Value? stockQuantity, + Value? lowStockThreshold, + Value? priceInCents, + Value? imagePath, + Value? active, + Value? rowid, + }) { + return ProductsCompanion( + id: id ?? this.id, + name: name ?? this.name, + category: category ?? this.category, + stockQuantity: stockQuantity ?? this.stockQuantity, + lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, + priceInCents: priceInCents ?? this.priceInCents, + imagePath: imagePath ?? this.imagePath, + active: active ?? this.active, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (stockQuantity.present) { + map['stock_quantity'] = Variable(stockQuantity.value); + } + if (lowStockThreshold.present) { + map['low_stock_threshold'] = Variable(lowStockThreshold.value); + } + if (priceInCents.present) { + map['price_in_cents'] = Variable(priceInCents.value); + } + if (imagePath.present) { + map['image_path'] = Variable(imagePath.value); + } + if (active.present) { + map['active'] = Variable(active.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProductsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('category: $category, ') + ..write('stockQuantity: $stockQuantity, ') + ..write('lowStockThreshold: $lowStockThreshold, ') + ..write('priceInCents: $priceInCents, ') + ..write('imagePath: $imagePath, ') + ..write('active: $active, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $BarTabsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _customerNameMeta = const VerificationMeta( + 'customerName', + ); + @override + late final GeneratedColumn customerName = GeneratedColumn( + 'customer_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('open'), + ); + static const VerificationMeta _openedAtMeta = const VerificationMeta( + 'openedAt', + ); + @override + late final GeneratedColumn openedAt = GeneratedColumn( + 'opened_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _closedAtMeta = const VerificationMeta( + 'closedAt', + ); + @override + late final GeneratedColumn closedAt = GeneratedColumn( + 'closed_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + customerName, + status, + openedAt, + closedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bar_tabs'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('customer_name')) { + context.handle( + _customerNameMeta, + customerName.isAcceptableOrUnknown( + data['customer_name']!, + _customerNameMeta, + ), + ); + } else if (isInserting) { + context.missing(_customerNameMeta); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } + if (data.containsKey('opened_at')) { + context.handle( + _openedAtMeta, + openedAt.isAcceptableOrUnknown(data['opened_at']!, _openedAtMeta), + ); + } else if (isInserting) { + context.missing(_openedAtMeta); + } + if (data.containsKey('closed_at')) { + context.handle( + _closedAtMeta, + closedAt.isAcceptableOrUnknown(data['closed_at']!, _closedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + BarTabRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BarTabRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + customerName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}customer_name'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + openedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}opened_at'], + )!, + closedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}closed_at'], + ), + ); + } + + @override + $BarTabsTable createAlias(String alias) { + return $BarTabsTable(attachedDatabase, alias); + } +} + +class BarTabRow extends DataClass implements Insertable { + final String id; + final String customerName; + final String status; + final DateTime openedAt; + final DateTime? closedAt; + const BarTabRow({ + required this.id, + required this.customerName, + required this.status, + required this.openedAt, + this.closedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['customer_name'] = Variable(customerName); + map['status'] = Variable(status); + map['opened_at'] = Variable(openedAt); + if (!nullToAbsent || closedAt != null) { + map['closed_at'] = Variable(closedAt); + } + return map; + } + + BarTabsCompanion toCompanion(bool nullToAbsent) { + return BarTabsCompanion( + id: Value(id), + customerName: Value(customerName), + status: Value(status), + openedAt: Value(openedAt), + closedAt: closedAt == null && nullToAbsent + ? const Value.absent() + : Value(closedAt), + ); + } + + factory BarTabRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BarTabRow( + id: serializer.fromJson(json['id']), + customerName: serializer.fromJson(json['customerName']), + status: serializer.fromJson(json['status']), + openedAt: serializer.fromJson(json['openedAt']), + closedAt: serializer.fromJson(json['closedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'customerName': serializer.toJson(customerName), + 'status': serializer.toJson(status), + 'openedAt': serializer.toJson(openedAt), + 'closedAt': serializer.toJson(closedAt), + }; + } + + BarTabRow copyWith({ + String? id, + String? customerName, + String? status, + DateTime? openedAt, + Value closedAt = const Value.absent(), + }) => BarTabRow( + id: id ?? this.id, + customerName: customerName ?? this.customerName, + status: status ?? this.status, + openedAt: openedAt ?? this.openedAt, + closedAt: closedAt.present ? closedAt.value : this.closedAt, + ); + BarTabRow copyWithCompanion(BarTabsCompanion data) { + return BarTabRow( + id: data.id.present ? data.id.value : this.id, + customerName: data.customerName.present + ? data.customerName.value + : this.customerName, + status: data.status.present ? data.status.value : this.status, + openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, + closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt, + ); + } + + @override + String toString() { + return (StringBuffer('BarTabRow(') + ..write('id: $id, ') + ..write('customerName: $customerName, ') + ..write('status: $status, ') + ..write('openedAt: $openedAt, ') + ..write('closedAt: $closedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, customerName, status, openedAt, closedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BarTabRow && + other.id == this.id && + other.customerName == this.customerName && + other.status == this.status && + other.openedAt == this.openedAt && + other.closedAt == this.closedAt); +} + +class BarTabsCompanion extends UpdateCompanion { + final Value id; + final Value customerName; + final Value status; + final Value openedAt; + final Value closedAt; + final Value rowid; + const BarTabsCompanion({ + this.id = const Value.absent(), + this.customerName = const Value.absent(), + this.status = const Value.absent(), + this.openedAt = const Value.absent(), + this.closedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + BarTabsCompanion.insert({ + required String id, + required String customerName, + this.status = const Value.absent(), + required DateTime openedAt, + this.closedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + customerName = Value(customerName), + openedAt = Value(openedAt); + static Insertable custom({ + Expression? id, + Expression? customerName, + Expression? status, + Expression? openedAt, + Expression? closedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (customerName != null) 'customer_name': customerName, + if (status != null) 'status': status, + if (openedAt != null) 'opened_at': openedAt, + if (closedAt != null) 'closed_at': closedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + BarTabsCompanion copyWith({ + Value? id, + Value? customerName, + Value? status, + Value? openedAt, + Value? closedAt, + Value? rowid, + }) { + return BarTabsCompanion( + id: id ?? this.id, + customerName: customerName ?? this.customerName, + status: status ?? this.status, + openedAt: openedAt ?? this.openedAt, + closedAt: closedAt ?? this.closedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (customerName.present) { + map['customer_name'] = Variable(customerName.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (openedAt.present) { + map['opened_at'] = Variable(openedAt.value); + } + if (closedAt.present) { + map['closed_at'] = Variable(closedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BarTabsCompanion(') + ..write('id: $id, ') + ..write('customerName: $customerName, ') + ..write('status: $status, ') + ..write('openedAt: $openedAt, ') + ..write('closedAt: $closedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $TabItemsTable extends TabItems + with TableInfo<$TabItemsTable, TabItemRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TabItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _tabIdMeta = const VerificationMeta('tabId'); + @override + late final GeneratedColumn tabId = GeneratedColumn( + 'tab_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES bar_tabs (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _productIdMeta = const VerificationMeta( + 'productId', + ); + @override + late final GeneratedColumn productId = GeneratedColumn( + 'product_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES products (id)', + ), + ); + static const VerificationMeta _productNameMeta = const VerificationMeta( + 'productName', + ); + @override + late final GeneratedColumn productName = GeneratedColumn( + 'product_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _quantityMeta = const VerificationMeta( + 'quantity', + ); + @override + late final GeneratedColumn quantity = GeneratedColumn( + 'quantity', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _unitPriceInCentsMeta = const VerificationMeta( + 'unitPriceInCents', + ); + @override + late final GeneratedColumn unitPriceInCents = GeneratedColumn( + 'unit_price_in_cents', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + tabId, + productId, + productName, + quantity, + unitPriceInCents, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_items'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('tab_id')) { + context.handle( + _tabIdMeta, + tabId.isAcceptableOrUnknown(data['tab_id']!, _tabIdMeta), + ); + } else if (isInserting) { + context.missing(_tabIdMeta); + } + if (data.containsKey('product_id')) { + context.handle( + _productIdMeta, + productId.isAcceptableOrUnknown(data['product_id']!, _productIdMeta), + ); + } else if (isInserting) { + context.missing(_productIdMeta); + } + if (data.containsKey('product_name')) { + context.handle( + _productNameMeta, + productName.isAcceptableOrUnknown( + data['product_name']!, + _productNameMeta, + ), + ); + } else if (isInserting) { + context.missing(_productNameMeta); + } + if (data.containsKey('quantity')) { + context.handle( + _quantityMeta, + quantity.isAcceptableOrUnknown(data['quantity']!, _quantityMeta), + ); + } else if (isInserting) { + context.missing(_quantityMeta); + } + if (data.containsKey('unit_price_in_cents')) { + context.handle( + _unitPriceInCentsMeta, + unitPriceInCents.isAcceptableOrUnknown( + data['unit_price_in_cents']!, + _unitPriceInCentsMeta, + ), + ); + } else if (isInserting) { + context.missing(_unitPriceInCentsMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + TabItemRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabItemRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + tabId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tab_id'], + )!, + productId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}product_id'], + )!, + productName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}product_name'], + )!, + quantity: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quantity'], + )!, + unitPriceInCents: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}unit_price_in_cents'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $TabItemsTable createAlias(String alias) { + return $TabItemsTable(attachedDatabase, alias); + } +} + +class TabItemRow extends DataClass implements Insertable { + final String id; + final String tabId; + final String productId; + final String productName; + final int quantity; + final int unitPriceInCents; + final DateTime createdAt; + const TabItemRow({ + required this.id, + required this.tabId, + required this.productId, + required this.productName, + required this.quantity, + required this.unitPriceInCents, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['tab_id'] = Variable(tabId); + map['product_id'] = Variable(productId); + map['product_name'] = Variable(productName); + map['quantity'] = Variable(quantity); + map['unit_price_in_cents'] = Variable(unitPriceInCents); + map['created_at'] = Variable(createdAt); + return map; + } + + TabItemsCompanion toCompanion(bool nullToAbsent) { + return TabItemsCompanion( + id: Value(id), + tabId: Value(tabId), + productId: Value(productId), + productName: Value(productName), + quantity: Value(quantity), + unitPriceInCents: Value(unitPriceInCents), + createdAt: Value(createdAt), + ); + } + + factory TabItemRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabItemRow( + id: serializer.fromJson(json['id']), + tabId: serializer.fromJson(json['tabId']), + productId: serializer.fromJson(json['productId']), + productName: serializer.fromJson(json['productName']), + quantity: serializer.fromJson(json['quantity']), + unitPriceInCents: serializer.fromJson(json['unitPriceInCents']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'tabId': serializer.toJson(tabId), + 'productId': serializer.toJson(productId), + 'productName': serializer.toJson(productName), + 'quantity': serializer.toJson(quantity), + 'unitPriceInCents': serializer.toJson(unitPriceInCents), + 'createdAt': serializer.toJson(createdAt), + }; + } + + TabItemRow copyWith({ + String? id, + String? tabId, + String? productId, + String? productName, + int? quantity, + int? unitPriceInCents, + DateTime? createdAt, + }) => TabItemRow( + id: id ?? this.id, + tabId: tabId ?? this.tabId, + productId: productId ?? this.productId, + productName: productName ?? this.productName, + quantity: quantity ?? this.quantity, + unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, + createdAt: createdAt ?? this.createdAt, + ); + TabItemRow copyWithCompanion(TabItemsCompanion data) { + return TabItemRow( + id: data.id.present ? data.id.value : this.id, + tabId: data.tabId.present ? data.tabId.value : this.tabId, + productId: data.productId.present ? data.productId.value : this.productId, + productName: data.productName.present + ? data.productName.value + : this.productName, + quantity: data.quantity.present ? data.quantity.value : this.quantity, + unitPriceInCents: data.unitPriceInCents.present + ? data.unitPriceInCents.value + : this.unitPriceInCents, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('TabItemRow(') + ..write('id: $id, ') + ..write('tabId: $tabId, ') + ..write('productId: $productId, ') + ..write('productName: $productName, ') + ..write('quantity: $quantity, ') + ..write('unitPriceInCents: $unitPriceInCents, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + tabId, + productId, + productName, + quantity, + unitPriceInCents, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabItemRow && + other.id == this.id && + other.tabId == this.tabId && + other.productId == this.productId && + other.productName == this.productName && + other.quantity == this.quantity && + other.unitPriceInCents == this.unitPriceInCents && + other.createdAt == this.createdAt); +} + +class TabItemsCompanion extends UpdateCompanion { + final Value id; + final Value tabId; + final Value productId; + final Value productName; + final Value quantity; + final Value unitPriceInCents; + final Value createdAt; + final Value rowid; + const TabItemsCompanion({ + this.id = const Value.absent(), + this.tabId = const Value.absent(), + this.productId = const Value.absent(), + this.productName = const Value.absent(), + this.quantity = const Value.absent(), + this.unitPriceInCents = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabItemsCompanion.insert({ + required String id, + required String tabId, + required String productId, + required String productName, + required int quantity, + required int unitPriceInCents, + required DateTime createdAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + tabId = Value(tabId), + productId = Value(productId), + productName = Value(productName), + quantity = Value(quantity), + unitPriceInCents = Value(unitPriceInCents), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? tabId, + Expression? productId, + Expression? productName, + Expression? quantity, + Expression? unitPriceInCents, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (tabId != null) 'tab_id': tabId, + if (productId != null) 'product_id': productId, + if (productName != null) 'product_name': productName, + if (quantity != null) 'quantity': quantity, + if (unitPriceInCents != null) 'unit_price_in_cents': unitPriceInCents, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + TabItemsCompanion copyWith({ + Value? id, + Value? tabId, + Value? productId, + Value? productName, + Value? quantity, + Value? unitPriceInCents, + Value? createdAt, + Value? rowid, + }) { + return TabItemsCompanion( + id: id ?? this.id, + tabId: tabId ?? this.tabId, + productId: productId ?? this.productId, + productName: productName ?? this.productName, + quantity: quantity ?? this.quantity, + unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (tabId.present) { + map['tab_id'] = Variable(tabId.value); + } + if (productId.present) { + map['product_id'] = Variable(productId.value); + } + if (productName.present) { + map['product_name'] = Variable(productName.value); + } + if (quantity.present) { + map['quantity'] = Variable(quantity.value); + } + if (unitPriceInCents.present) { + map['unit_price_in_cents'] = Variable(unitPriceInCents.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabItemsCompanion(') + ..write('id: $id, ') + ..write('tabId: $tabId, ') + ..write('productId: $productId, ') + ..write('productName: $productName, ') + ..write('quantity: $quantity, ') + ..write('unitPriceInCents: $unitPriceInCents, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $ProductsTable products = $ProductsTable(this); + late final $BarTabsTable barTabs = $BarTabsTable(this); + late final $TabItemsTable tabItems = $TabItemsTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + products, + barTabs, + tabItems, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'bar_tabs', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('tab_items', kind: UpdateKind.delete)], + ), + ]); +} + +typedef $$ProductsTableCreateCompanionBuilder = + ProductsCompanion Function({ + required String id, + required String name, + required String category, + required int stockQuantity, + required int lowStockThreshold, + required int priceInCents, + Value imagePath, + Value active, + Value rowid, + }); +typedef $$ProductsTableUpdateCompanionBuilder = + ProductsCompanion Function({ + Value id, + Value name, + Value category, + Value stockQuantity, + Value lowStockThreshold, + Value priceInCents, + Value imagePath, + Value active, + Value rowid, + }); + +final class $$ProductsTableReferences + extends BaseReferences<_$AppDatabase, $ProductsTable, ProductRow> { + $$ProductsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$TabItemsTable, List> + _tabItemsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.tabItems, + aliasName: 'products__id__tab_items__product_id', + ); + + $$TabItemsTableProcessedTableManager get tabItemsRefs { + final manager = $$TabItemsTableTableManager( + $_db, + $_db.tabItems, + ).filter((f) => f.productId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_tabItemsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$ProductsTableFilterComposer + extends Composer<_$AppDatabase, $ProductsTable> { + $$ProductsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stockQuantity => $composableBuilder( + column: $table.stockQuantity, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lowStockThreshold => $composableBuilder( + column: $table.lowStockThreshold, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get priceInCents => $composableBuilder( + column: $table.priceInCents, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get imagePath => $composableBuilder( + column: $table.imagePath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get active => $composableBuilder( + column: $table.active, + builder: (column) => ColumnFilters(column), + ); + + Expression tabItemsRefs( + Expression Function($$TabItemsTableFilterComposer f) f, + ) { + final $$TabItemsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tabItems, + getReferencedColumn: (t) => t.productId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemsTableFilterComposer( + $db: $db, + $table: $db.tabItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ProductsTableOrderingComposer + extends Composer<_$AppDatabase, $ProductsTable> { + $$ProductsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stockQuantity => $composableBuilder( + column: $table.stockQuantity, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lowStockThreshold => $composableBuilder( + column: $table.lowStockThreshold, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get priceInCents => $composableBuilder( + column: $table.priceInCents, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get imagePath => $composableBuilder( + column: $table.imagePath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get active => $composableBuilder( + column: $table.active, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ProductsTableAnnotationComposer + extends Composer<_$AppDatabase, $ProductsTable> { + $$ProductsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get stockQuantity => $composableBuilder( + column: $table.stockQuantity, + builder: (column) => column, + ); + + GeneratedColumn get lowStockThreshold => $composableBuilder( + column: $table.lowStockThreshold, + builder: (column) => column, + ); + + GeneratedColumn get priceInCents => $composableBuilder( + column: $table.priceInCents, + builder: (column) => column, + ); + + GeneratedColumn get imagePath => + $composableBuilder(column: $table.imagePath, builder: (column) => column); + + GeneratedColumn get active => + $composableBuilder(column: $table.active, builder: (column) => column); + + Expression tabItemsRefs( + Expression Function($$TabItemsTableAnnotationComposer a) f, + ) { + final $$TabItemsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tabItems, + getReferencedColumn: (t) => t.productId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemsTableAnnotationComposer( + $db: $db, + $table: $db.tabItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ProductsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ProductsTable, + ProductRow, + $$ProductsTableFilterComposer, + $$ProductsTableOrderingComposer, + $$ProductsTableAnnotationComposer, + $$ProductsTableCreateCompanionBuilder, + $$ProductsTableUpdateCompanionBuilder, + (ProductRow, $$ProductsTableReferences), + ProductRow, + PrefetchHooks Function({bool tabItemsRefs}) + > { + $$ProductsTableTableManager(_$AppDatabase db, $ProductsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ProductsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ProductsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ProductsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value category = const Value.absent(), + Value stockQuantity = const Value.absent(), + Value lowStockThreshold = const Value.absent(), + Value priceInCents = const Value.absent(), + Value imagePath = const Value.absent(), + Value active = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProductsCompanion( + id: id, + name: name, + category: category, + stockQuantity: stockQuantity, + lowStockThreshold: lowStockThreshold, + priceInCents: priceInCents, + imagePath: imagePath, + active: active, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String name, + required String category, + required int stockQuantity, + required int lowStockThreshold, + required int priceInCents, + Value imagePath = const Value.absent(), + Value active = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProductsCompanion.insert( + id: id, + name: name, + category: category, + stockQuantity: stockQuantity, + lowStockThreshold: lowStockThreshold, + priceInCents: priceInCents, + imagePath: imagePath, + active: active, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ProductsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({tabItemsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (tabItemsRefs) db.tabItems], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (tabItemsRefs) + await $_getPrefetchedData< + ProductRow, + $ProductsTable, + TabItemRow + >( + currentTable: table, + referencedTable: $$ProductsTableReferences + ._tabItemsRefsTable(db), + managerFromTypedResult: (p0) => + $$ProductsTableReferences(db, table, p0).tabItemsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.productId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$ProductsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ProductsTable, + ProductRow, + $$ProductsTableFilterComposer, + $$ProductsTableOrderingComposer, + $$ProductsTableAnnotationComposer, + $$ProductsTableCreateCompanionBuilder, + $$ProductsTableUpdateCompanionBuilder, + (ProductRow, $$ProductsTableReferences), + ProductRow, + PrefetchHooks Function({bool tabItemsRefs}) + >; +typedef $$BarTabsTableCreateCompanionBuilder = + BarTabsCompanion Function({ + required String id, + required String customerName, + Value status, + required DateTime openedAt, + Value closedAt, + Value rowid, + }); +typedef $$BarTabsTableUpdateCompanionBuilder = + BarTabsCompanion Function({ + Value id, + Value customerName, + Value status, + Value openedAt, + Value closedAt, + Value rowid, + }); + +final class $$BarTabsTableReferences + extends BaseReferences<_$AppDatabase, $BarTabsTable, BarTabRow> { + $$BarTabsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$TabItemsTable, List> + _tabItemsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.tabItems, + aliasName: 'bar_tabs__id__tab_items__tab_id', + ); + + $$TabItemsTableProcessedTableManager get tabItemsRefs { + final manager = $$TabItemsTableTableManager( + $_db, + $_db.tabItems, + ).filter((f) => f.tabId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_tabItemsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$BarTabsTableFilterComposer + extends Composer<_$AppDatabase, $BarTabsTable> { + $$BarTabsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get customerName => $composableBuilder( + column: $table.customerName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get openedAt => $composableBuilder( + column: $table.openedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get closedAt => $composableBuilder( + column: $table.closedAt, + builder: (column) => ColumnFilters(column), + ); + + Expression tabItemsRefs( + Expression Function($$TabItemsTableFilterComposer f) f, + ) { + final $$TabItemsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tabItems, + getReferencedColumn: (t) => t.tabId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemsTableFilterComposer( + $db: $db, + $table: $db.tabItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$BarTabsTableOrderingComposer + extends Composer<_$AppDatabase, $BarTabsTable> { + $$BarTabsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get customerName => $composableBuilder( + column: $table.customerName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get openedAt => $composableBuilder( + column: $table.openedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get closedAt => $composableBuilder( + column: $table.closedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$BarTabsTableAnnotationComposer + extends Composer<_$AppDatabase, $BarTabsTable> { + $$BarTabsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get customerName => $composableBuilder( + column: $table.customerName, + builder: (column) => column, + ); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get openedAt => + $composableBuilder(column: $table.openedAt, builder: (column) => column); + + GeneratedColumn get closedAt => + $composableBuilder(column: $table.closedAt, builder: (column) => column); + + Expression tabItemsRefs( + Expression Function($$TabItemsTableAnnotationComposer a) f, + ) { + final $$TabItemsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tabItems, + getReferencedColumn: (t) => t.tabId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TabItemsTableAnnotationComposer( + $db: $db, + $table: $db.tabItems, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$BarTabsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $BarTabsTable, + BarTabRow, + $$BarTabsTableFilterComposer, + $$BarTabsTableOrderingComposer, + $$BarTabsTableAnnotationComposer, + $$BarTabsTableCreateCompanionBuilder, + $$BarTabsTableUpdateCompanionBuilder, + (BarTabRow, $$BarTabsTableReferences), + BarTabRow, + PrefetchHooks Function({bool tabItemsRefs}) + > { + $$BarTabsTableTableManager(_$AppDatabase db, $BarTabsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$BarTabsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$BarTabsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$BarTabsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value customerName = const Value.absent(), + Value status = const Value.absent(), + Value openedAt = const Value.absent(), + Value closedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => BarTabsCompanion( + id: id, + customerName: customerName, + status: status, + openedAt: openedAt, + closedAt: closedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String customerName, + Value status = const Value.absent(), + required DateTime openedAt, + Value closedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => BarTabsCompanion.insert( + id: id, + customerName: customerName, + status: status, + openedAt: openedAt, + closedAt: closedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$BarTabsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({tabItemsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (tabItemsRefs) db.tabItems], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (tabItemsRefs) + await $_getPrefetchedData< + BarTabRow, + $BarTabsTable, + TabItemRow + >( + currentTable: table, + referencedTable: $$BarTabsTableReferences + ._tabItemsRefsTable(db), + managerFromTypedResult: (p0) => + $$BarTabsTableReferences(db, table, p0).tabItemsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.tabId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$BarTabsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $BarTabsTable, + BarTabRow, + $$BarTabsTableFilterComposer, + $$BarTabsTableOrderingComposer, + $$BarTabsTableAnnotationComposer, + $$BarTabsTableCreateCompanionBuilder, + $$BarTabsTableUpdateCompanionBuilder, + (BarTabRow, $$BarTabsTableReferences), + BarTabRow, + PrefetchHooks Function({bool tabItemsRefs}) + >; +typedef $$TabItemsTableCreateCompanionBuilder = + TabItemsCompanion Function({ + required String id, + required String tabId, + required String productId, + required String productName, + required int quantity, + required int unitPriceInCents, + required DateTime createdAt, + Value rowid, + }); +typedef $$TabItemsTableUpdateCompanionBuilder = + TabItemsCompanion Function({ + Value id, + Value tabId, + Value productId, + Value productName, + Value quantity, + Value unitPriceInCents, + Value createdAt, + Value rowid, + }); + +final class $$TabItemsTableReferences + extends BaseReferences<_$AppDatabase, $TabItemsTable, TabItemRow> { + $$TabItemsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $BarTabsTable _tabIdTable(_$AppDatabase db) => + db.barTabs.createAlias('tab_items__tab_id__bar_tabs__id'); + + $$BarTabsTableProcessedTableManager get tabId { + final $_column = $_itemColumn('tab_id')!; + + final manager = $$BarTabsTableTableManager( + $_db, + $_db.barTabs, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_tabIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $ProductsTable _productIdTable(_$AppDatabase db) => + db.products.createAlias('tab_items__product_id__products__id'); + + $$ProductsTableProcessedTableManager get productId { + final $_column = $_itemColumn('product_id')!; + + final manager = $$ProductsTableTableManager( + $_db, + $_db.products, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_productIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$TabItemsTableFilterComposer + extends Composer<_$AppDatabase, $TabItemsTable> { + $$TabItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get productName => $composableBuilder( + column: $table.productName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get quantity => $composableBuilder( + column: $table.quantity, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get unitPriceInCents => $composableBuilder( + column: $table.unitPriceInCents, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + $$BarTabsTableFilterComposer get tabId { + final $$BarTabsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabId, + referencedTable: $db.barTabs, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$BarTabsTableFilterComposer( + $db: $db, + $table: $db.barTabs, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$ProductsTableFilterComposer get productId { + final $$ProductsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.productId, + referencedTable: $db.products, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProductsTableFilterComposer( + $db: $db, + $table: $db.products, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TabItemsTableOrderingComposer + extends Composer<_$AppDatabase, $TabItemsTable> { + $$TabItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get productName => $composableBuilder( + column: $table.productName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get quantity => $composableBuilder( + column: $table.quantity, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get unitPriceInCents => $composableBuilder( + column: $table.unitPriceInCents, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + $$BarTabsTableOrderingComposer get tabId { + final $$BarTabsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabId, + referencedTable: $db.barTabs, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$BarTabsTableOrderingComposer( + $db: $db, + $table: $db.barTabs, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$ProductsTableOrderingComposer get productId { + final $$ProductsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.productId, + referencedTable: $db.products, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProductsTableOrderingComposer( + $db: $db, + $table: $db.products, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TabItemsTableAnnotationComposer + extends Composer<_$AppDatabase, $TabItemsTable> { + $$TabItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get productName => $composableBuilder( + column: $table.productName, + builder: (column) => column, + ); + + GeneratedColumn get quantity => + $composableBuilder(column: $table.quantity, builder: (column) => column); + + GeneratedColumn get unitPriceInCents => $composableBuilder( + column: $table.unitPriceInCents, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + $$BarTabsTableAnnotationComposer get tabId { + final $$BarTabsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.tabId, + referencedTable: $db.barTabs, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$BarTabsTableAnnotationComposer( + $db: $db, + $table: $db.barTabs, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$ProductsTableAnnotationComposer get productId { + final $$ProductsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.productId, + referencedTable: $db.products, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProductsTableAnnotationComposer( + $db: $db, + $table: $db.products, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TabItemsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TabItemsTable, + TabItemRow, + $$TabItemsTableFilterComposer, + $$TabItemsTableOrderingComposer, + $$TabItemsTableAnnotationComposer, + $$TabItemsTableCreateCompanionBuilder, + $$TabItemsTableUpdateCompanionBuilder, + (TabItemRow, $$TabItemsTableReferences), + TabItemRow, + PrefetchHooks Function({bool tabId, bool productId}) + > { + $$TabItemsTableTableManager(_$AppDatabase db, $TabItemsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TabItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TabItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TabItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value tabId = const Value.absent(), + Value productId = const Value.absent(), + Value productName = const Value.absent(), + Value quantity = const Value.absent(), + Value unitPriceInCents = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => TabItemsCompanion( + id: id, + tabId: tabId, + productId: productId, + productName: productName, + quantity: quantity, + unitPriceInCents: unitPriceInCents, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String tabId, + required String productId, + required String productName, + required int quantity, + required int unitPriceInCents, + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => TabItemsCompanion.insert( + id: id, + tabId: tabId, + productId: productId, + productName: productName, + quantity: quantity, + unitPriceInCents: unitPriceInCents, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$TabItemsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({tabId = false, productId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (tabId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.tabId, + referencedTable: $$TabItemsTableReferences + ._tabIdTable(db), + referencedColumn: $$TabItemsTableReferences + ._tabIdTable(db) + .id, + ) + as T; + } + if (productId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.productId, + referencedTable: $$TabItemsTableReferences + ._productIdTable(db), + referencedColumn: $$TabItemsTableReferences + ._productIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$TabItemsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TabItemsTable, + TabItemRow, + $$TabItemsTableFilterComposer, + $$TabItemsTableOrderingComposer, + $$TabItemsTableAnnotationComposer, + $$TabItemsTableCreateCompanionBuilder, + $$TabItemsTableUpdateCompanionBuilder, + (TabItemRow, $$TabItemsTableReferences), + TabItemRow, + PrefetchHooks Function({bool tabId, bool productId}) + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$ProductsTableTableManager get products => + $$ProductsTableTableManager(_db, _db.products); + $$BarTabsTableTableManager get barTabs => + $$BarTabsTableTableManager(_db, _db.barTabs); + $$TabItemsTableTableManager get tabItems => + $$TabItemsTableTableManager(_db, _db.tabItems); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..6bd7147 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import 'app/app.dart'; +import 'app/app_bootstrap.dart'; +import 'database/app_database.dart'; +import 'services/bar_tab_service.dart'; +import 'services/product_service.dart'; +import 'viewmodels/bar_screen_view_model.dart'; +import 'viewmodels/product_list_view_model.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + + runApp( + MultiProvider( + providers: [ + Provider( + create: (_) => AppDatabase(), + dispose: (_, database) => database.close(), + ), + Provider( + create: (context) => DriftProductService( + database: context.read(), + ), + ), + Provider( + create: (context) => DriftBarTabService( + database: context.read(), + ), + ), + ChangeNotifierProvider( + create: (context) => ProductListViewModel( + productService: context.read(), + ), + ), + ChangeNotifierProvider( + create: (context) => BarScreenViewModel( + barTabService: context.read(), + ), + ), + ], + child: const AppBootstrap( + child: KoolTabApp(), + ), + ), + ); +} \ No newline at end of file diff --git a/lib/models/bar_tab.dart b/lib/models/bar_tab.dart new file mode 100644 index 0000000..c2ed317 --- /dev/null +++ b/lib/models/bar_tab.dart @@ -0,0 +1,39 @@ +import 'tab_item.dart'; + +class BarTab { + final String id; + final String customerName; + final String status; + final DateTime openedAt; + final DateTime? closedAt; + final List items; + + const BarTab({ + required this.id, + required this.customerName, + required this.status, + required this.openedAt, + required this.items, + this.closedAt, + }); + + int get totalInCents { + return items.fold( + 0, + (total, item) => total + item.lineTotalInCents, + ); + } + + int get itemCount { + return items.fold( + 0, + (total, item) => total + item.quantity, + ); + } + + String get formattedTotal { + return '€${(totalInCents / 100).toStringAsFixed(2)}'; + } + + bool get isOpen => status == 'open'; +} \ No newline at end of file diff --git a/lib/models/product.dart b/lib/models/product.dart new file mode 100644 index 0000000..2e00110 --- /dev/null +++ b/lib/models/product.dart @@ -0,0 +1,51 @@ +class Product { + final String id; + final String name; + final String category; + final int stockQuantity; + final int lowStockThreshold; + final int priceInCents; + final String? imagePath; + final bool active; + + const Product({ + required this.id, + required this.name, + required this.category, + required this.stockQuantity, + required this.lowStockThreshold, + required this.priceInCents, + this.imagePath, + this.active = true, + }); + + bool get isLowStock => stockQuantity <= lowStockThreshold; + + String get formattedPrice { + final euros = priceInCents / 100; + + return '€${euros.toStringAsFixed(2)}'; + } + + Product copyWith({ + String? id, + String? name, + String? category, + int? stockQuantity, + int? lowStockThreshold, + int? priceInCents, + String? imagePath, + bool? active, + }) { + return Product( + id: id ?? this.id, + name: name ?? this.name, + category: category ?? this.category, + stockQuantity: stockQuantity ?? this.stockQuantity, + lowStockThreshold: lowStockThreshold ?? this.lowStockThreshold, + priceInCents: priceInCents ?? this.priceInCents, + imagePath: imagePath ?? this.imagePath, + active: active ?? this.active, + ); + } +} \ No newline at end of file diff --git a/lib/models/tab_item.dart b/lib/models/tab_item.dart new file mode 100644 index 0000000..adf9869 --- /dev/null +++ b/lib/models/tab_item.dart @@ -0,0 +1,27 @@ +class TabItem { + final String id; + final String tabId; + final String productId; + final String productName; + final int quantity; + final int unitPriceInCents; + + const TabItem({ + required this.id, + required this.tabId, + required this.productId, + required this.productName, + required this.quantity, + required this.unitPriceInCents, + }); + + int get lineTotalInCents => quantity * unitPriceInCents; + + String get formattedLineTotal { + return '€${(lineTotalInCents / 100).toStringAsFixed(2)}'; + } + + String get formattedUnitPrice { + return '€${(unitPriceInCents / 100).toStringAsFixed(2)}'; + } +} \ No newline at end of file diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart new file mode 100644 index 0000000..e8d8e94 --- /dev/null +++ b/lib/services/bar_tab_service.dart @@ -0,0 +1,214 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../database/app_database.dart'; +import '../models/bar_tab.dart'; +import '../models/product.dart'; +import '../models/tab_item.dart'; + +abstract class BarTabService { + Future> getOpenTabs(); + + Future getTabById(String id); + + Future createTab({ + required String customerName, + }); + + Future addProductToTab({ + required String tabId, + required Product product, + }); + + Future updateTabItemQuantity({ + required String tabItemId, + required int quantity, + }); + + Future closeTab(String tabId); +} + +class DriftBarTabService implements BarTabService { + final AppDatabase database; + final _uuid = const Uuid(); + + DriftBarTabService({ + required this.database, + }); + + TabItem _mapItemRow(TabItemRow row) { + return TabItem( + id: row.id, + tabId: row.tabId, + productId: row.productId, + productName: row.productName, + quantity: row.quantity, + unitPriceInCents: row.unitPriceInCents, + ); + } + + BarTab _mapTabRow( + BarTabRow row, + List items, + ) { + return BarTab( + id: row.id, + customerName: row.customerName, + status: row.status, + openedAt: row.openedAt, + closedAt: row.closedAt, + items: items, + ); + } + + Future> _getItemsForTab(String tabId) async { + final query = database.select(database.tabItems) + ..where((item) => item.tabId.equals(tabId)) + ..orderBy([ + (item) => OrderingTerm.asc(item.createdAt), + ]); + + final rows = await query.get(); + + return rows.map(_mapItemRow).toList(); + } + + @override + Future> getOpenTabs() async { + final query = database.select(database.barTabs) + ..where((tab) => tab.status.equals('open')) + ..orderBy([ + (tab) => OrderingTerm.desc(tab.openedAt), + ]); + + final tabRows = await query.get(); + + final tabs = []; + + for (final tabRow in tabRows) { + final items = await _getItemsForTab(tabRow.id); + + tabs.add(_mapTabRow(tabRow, items)); + } + + return tabs; + } + + @override + Future getTabById(String id) async { + final query = database.select(database.barTabs) + ..where((tab) => tab.id.equals(id)); + + final tabRow = await query.getSingleOrNull(); + + if (tabRow == null) { + return null; + } + + final items = await _getItemsForTab(tabRow.id); + + return _mapTabRow(tabRow, items); + } + + @override + Future createTab({ + required String customerName, + }) async { + final id = _uuid.v4(); + + await database.into(database.barTabs).insert( + BarTabsCompanion.insert( + id: id, + customerName: customerName, + status: const Value('open'), + openedAt: DateTime.now(), + ), + ); + + final tab = await getTabById(id); + + if (tab == null) { + throw Exception('Could not create tab.'); + } + + return tab; + } + + @override + Future addProductToTab({ + required String tabId, + required Product product, + }) async { + await database.transaction(() async { + final existingItemQuery = database.select(database.tabItems) + ..where( + (item) => + item.tabId.equals(tabId) & + item.productId.equals(product.id), + ); + + final existingItem = await existingItemQuery.getSingleOrNull(); + + if (existingItem != null) { + final updateQuery = database.update(database.tabItems) + ..where((item) => item.id.equals(existingItem.id)); + + await updateQuery.write( + TabItemsCompanion( + quantity: Value(existingItem.quantity + 1), + ), + ); + + return; + } + + await database.into(database.tabItems).insert( + TabItemsCompanion.insert( + id: _uuid.v4(), + tabId: tabId, + productId: product.id, + productName: product.name, + quantity: 1, + unitPriceInCents: product.priceInCents, + createdAt: DateTime.now(), + ), + ); + }); + } + + @override + Future updateTabItemQuantity({ + required String tabItemId, + required int quantity, + }) async { + if (quantity <= 0) { + final deleteQuery = database.delete(database.tabItems) + ..where((item) => item.id.equals(tabItemId)); + + await deleteQuery.go(); + return; + } + + final updateQuery = database.update(database.tabItems) + ..where((item) => item.id.equals(tabItemId)); + + await updateQuery.write( + TabItemsCompanion( + quantity: Value(quantity), + ), + ); + } + + @override + Future closeTab(String tabId) async { + final updateQuery = database.update(database.barTabs) + ..where((tab) => tab.id.equals(tabId)); + + await updateQuery.write( + BarTabsCompanion( + status: const Value('closed'), + closedAt: Value(DateTime.now()), + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart new file mode 100644 index 0000000..dccb654 --- /dev/null +++ b/lib/services/product_service.dart @@ -0,0 +1,121 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../database/app_database.dart'; +import '../models/product.dart'; + +abstract class ProductService { + Future> getProducts(); + + Future getProductById(String id); + + Future createProduct({ + required String name, + required String category, + required int stockQuantity, + required int lowStockThreshold, + required int priceInCents, + required String? imagePath, + }); + + Future updateProduct(Product product); + + Future deleteProduct(String id); +} + +class DriftProductService implements ProductService { + final AppDatabase database; + final _uuid = const Uuid(); + + DriftProductService({ + required this.database, + }); + + Product _mapRowToProduct(ProductRow row) { + return Product( + id: row.id, + name: row.name, + category: row.category, + stockQuantity: row.stockQuantity, + lowStockThreshold: row.lowStockThreshold, + priceInCents: row.priceInCents, + imagePath: row.imagePath, + active: row.active, + ); + } + + @override + Future> getProducts() async { + final query = database.select(database.products) + ..where((product) => product.active.equals(true)) + ..orderBy([ + (product) => OrderingTerm.asc(product.name), + ]); + + final rows = await query.get(); + + return rows.map(_mapRowToProduct).toList(); + } + + @override + Future getProductById(String id) async { + final query = database.select(database.products) + ..where((product) => product.id.equals(id)); + + final row = await query.getSingleOrNull(); + + if (row == null) { + return null; + } + + return _mapRowToProduct(row); + } + + @override + Future createProduct({ + required String name, + required String category, + required int stockQuantity, + required int lowStockThreshold, + required int priceInCents, + required String? imagePath, + }) async { + await database.into(database.products).insert( + ProductsCompanion.insert( + id: _uuid.v4(), + name: name, + category: category, + stockQuantity: stockQuantity, + lowStockThreshold: lowStockThreshold, + priceInCents: priceInCents, + imagePath: Value(imagePath), + ), + ); + } + + @override + Future updateProduct(Product product) async { + final query = database.update(database.products) + ..where((row) => row.id.equals(product.id)); + + await query.write( + ProductsCompanion( + name: Value(product.name), + category: Value(product.category), + stockQuantity: Value(product.stockQuantity), + lowStockThreshold: Value(product.lowStockThreshold), + priceInCents: Value(product.priceInCents), + imagePath: Value(product.imagePath), + active: Value(product.active), + ), + ); + } + + @override + Future deleteProduct(String id) async { + final query = database.delete(database.products) + ..where((product) => product.id.equals(id)); + + await query.go(); + } +} \ No newline at end of file diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..3b8cb22 --- /dev/null +++ b/lib/theme.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; + +final ThemeData darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF7C4DFF), + brightness: Brightness.dark, + ), + + scaffoldBackgroundColor: const Color(0xFF121212), + canvasColor: const Color(0xFF121212), + + appBarTheme: const AppBarTheme( + elevation: 0, + centerTitle: true, + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + titleTextStyle: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + + cardTheme: CardThemeData( + color: const Color(0xFF1E1E1E), + elevation: 0, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: Colors.white.withOpacity(0.06), + ), + ), + ), + + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: const Color(0xFF7C4DFF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withOpacity(0.15), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF242424), + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: Colors.white.withOpacity(0.08), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide( + color: Color(0xFF7C4DFF), + width: 2, + ), + ), + ), + + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: Color(0xFF7C4DFF), + foregroundColor: Colors.white, + elevation: 2, + ), + + navigationBarTheme: NavigationBarThemeData( + backgroundColor: const Color(0xFF1A1A1A), + indicatorColor: const Color(0xFF7C4DFF).withOpacity(0.25), + labelTextStyle: WidgetStatePropertyAll( + TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + + listTileTheme: ListTileThemeData( + tileColor: const Color(0xFF1E1E1E), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + iconColor: const Color(0xFFB39DFF), + textColor: Colors.white, + ), + + snackBarTheme: SnackBarThemeData( + backgroundColor: const Color(0xFF2A2A2A), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + + dividerTheme: DividerThemeData( + color: Colors.white.withOpacity(0.08), + thickness: 1, + ), + + textTheme: const TextTheme( + displayLarge: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + headlineMedium: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + titleLarge: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + bodyLarge: TextStyle( + color: Colors.white70, + fontSize: 16, + height: 1.5, + ), + bodyMedium: TextStyle( + color: Colors.white60, + height: 1.4, + ), + labelLarge: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), +); + +final ThemeData neoBrutalDarkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + + colorScheme: const ColorScheme.dark( + primary: Color(0xFFFFD60A), // Bright yellow + secondary: Color(0xFF00E5FF), // Cyan + surface: Color(0xFF1A1A1A), + error: Color(0xFFFF5252), + ), + + scaffoldBackgroundColor: const Color(0xFF0E0E0E), + canvasColor: const Color(0xFF0E0E0E), + + textTheme: const TextTheme( + displayLarge: TextStyle( + fontSize: 48, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: -1, + ), + headlineMedium: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + titleLarge: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + bodyLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + bodyMedium: TextStyle( + fontSize: 14, + color: Colors.white70, + ), + ), + + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF0E0E0E), + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + + cardTheme: CardThemeData( + color: const Color(0xFF242424), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide( + color: Colors.white, + width: 3, + ), + ), + ), + + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFFD60A), + foregroundColor: Colors.black, + elevation: 0, + shadowColor: Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 18, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide( + color: Colors.black, + width: 3, + ), + ), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 16, + ), + ), + ), + + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF242424), + contentPadding: const EdgeInsets.all(18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.white, + width: 3, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.white, + width: 3, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFFFFD60A), + width: 4, + ), + ), + ), + + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: Color(0xFFFF5252), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + side: BorderSide( + color: Colors.white, + width: 3, + ), + ), + ), + + snackBarTheme: SnackBarThemeData( + backgroundColor: const Color(0xFF242424), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide( + color: Colors.white, + width: 3, + ), + ), + behavior: SnackBarBehavior.floating, + ), + + dividerTheme: const DividerThemeData( + color: Colors.white, + thickness: 3, + ), + + listTileTheme: ListTileThemeData( + tileColor: const Color(0xFF242424), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide( + color: Colors.white, + width: 3, + ), + ), + textColor: Colors.white, + iconColor: const Color(0xFFFFD60A), + ), +); \ No newline at end of file diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart new file mode 100644 index 0000000..b062614 --- /dev/null +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -0,0 +1,140 @@ +import 'package:flutter/foundation.dart'; + +import '../models/bar_tab.dart'; +import '../models/product.dart'; +import '../models/tab_item.dart'; +import '../services/bar_tab_service.dart'; +import '../services/product_service.dart'; + +class BarScreenViewModel extends ChangeNotifier { + final BarTabService barTabService; + + BarScreenViewModel({ + required this.barTabService, + }); + + List _tabs = []; + String? _selectedTabId; + bool _isLoading = false; + bool _hasLoaded = false; + String? _errorMessage; + + List get tabs => _tabs; + + String? get selectedTabId => _selectedTabId; + + bool get isLoading => _isLoading; + + bool get hasLoaded => _hasLoaded; + + String? get errorMessage => _errorMessage; + + BarTab? get selectedTab { + if (_selectedTabId == null) return null; + + try { + return _tabs.firstWhere((tab) => tab.id == _selectedTabId); + } catch (_) { + return null; + } + } + + Future ensureLoaded() async { + if (_hasLoaded || _isLoading) return; + + await load(); + } + + Future load() async { + if (_isLoading) return; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _tabs = await barTabService.getOpenTabs(); + + if (_selectedTabId == null || + !_tabs.any((tab) => tab.id == _selectedTabId)) { + _selectedTabId = _tabs.isEmpty ? null : _tabs.first.id; + } + } catch (_) { + _errorMessage = 'Could not load bar screen.'; + } finally { + _hasLoaded = true; + _isLoading = false; + notifyListeners(); + } + } + + void selectTab(String tabId) { + _selectedTabId = tabId; + notifyListeners(); + } + + Future createTab(String customerName) async { + final tab = await barTabService.createTab( + customerName: customerName, + ); + + _selectedTabId = tab.id; + await _reloadTabs(); + } + + Future addProductToSelectedTab(Product product) async { + final tab = selectedTab; + + if (tab == null) return; + + await barTabService.addProductToTab( + tabId: tab.id, + product: product, + ); + + await _reloadTabs(); + } + + Future changeItemQuantity( + TabItem item, + int quantity, + ) async { + await barTabService.updateTabItemQuantity( + tabItemId: item.id, + quantity: quantity, + ); + + await _reloadTabs(); + } + + Future closeSelectedTab() async { + final tab = selectedTab; + + if (tab == null) return; + + await barTabService.closeTab(tab.id); + + _selectedTabId = null; + await _reloadTabs(); + } + + Future closeTab(String tabId) async { + await barTabService.closeTab(tabId); + + if (_selectedTabId == tabId) { + _selectedTabId = null; + } + + await _reloadTabs(); + } + + Future _reloadTabs() async { + _tabs = await barTabService.getOpenTabs(); + + if (_selectedTabId == null || !_tabs.any((tab) => tab.id == _selectedTabId)) { + _selectedTabId = _tabs.isEmpty ? null : _tabs.first.id; + } + + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/viewmodels/product_list_view_model.dart b/lib/viewmodels/product_list_view_model.dart new file mode 100644 index 0000000..8658778 --- /dev/null +++ b/lib/viewmodels/product_list_view_model.dart @@ -0,0 +1,107 @@ +import 'package:flutter/foundation.dart'; + +import '../models/product.dart'; +import '../services/product_service.dart'; + +class ProductListViewModel extends ChangeNotifier { + final ProductService productService; + + ProductListViewModel({ + required this.productService, + }); + + static const List _defaultCategories = [ + 'Bier', + 'Wijn', + 'Frisdrank', + 'Cocktails', + 'Snacks', + 'Coffee', + 'Thee', + 'Other', + ]; + + List _products = []; + bool _isLoading = false; + bool _hasLoaded = false; + String? _errorMessage; + + List get products => _products; + + bool get isLoading => _isLoading; + + bool get hasLoaded => _hasLoaded; + + String? get errorMessage => _errorMessage; + + Future ensureLoaded() async { + if (_hasLoaded || _isLoading) return; + + await loadProducts(); + } + + Future loadProducts() async { + if (_isLoading) return; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _products = await productService.getProducts(); + } catch (_) { + _errorMessage = 'Could not load products.'; + } finally { + _hasLoaded = true; + _isLoading = false; + notifyListeners(); + } + } + + Future addProduct({ + required String name, + required String category, + required int stockQuantity, + required int lowStockThreshold, + required int priceInCents, + required String? imagePath, + }) async { + await productService.createProduct( + name: name, + category: category, + stockQuantity: stockQuantity, + lowStockThreshold: lowStockThreshold, + priceInCents: priceInCents, + imagePath: imagePath, + ); + + await loadProducts(); + } + + Future updateProduct(Product product) async { + await productService.updateProduct(product); + await loadProducts(); + } + + Future deleteProduct(String id) async { + await productService.deleteProduct(id); + await loadProducts(); + } + + Future getProductById(String id) { + return productService.getProductById(id); + } + + List get categories { + final categories = { + ..._defaultCategories, + ..._products + .map((p) => p.category.trim()) + .where((c) => c.isNotEmpty), + }.toList(); + + categories.sort(); + + return categories; + } +} \ No newline at end of file diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart new file mode 100644 index 0000000..63d6852 --- /dev/null +++ b/lib/views/bar_screen_view.dart @@ -0,0 +1,578 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widget_previews.dart'; +import 'package:go_router/go_router.dart'; +import 'package:kooltab2/viewmodels/product_list_view_model.dart'; +import 'package:provider/provider.dart'; +import 'dart:io'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +import '../models/bar_tab.dart'; +import '../models/product.dart'; +import '../models/tab_item.dart'; +import '../viewmodels/bar_screen_view_model.dart'; + +class BarScreenView extends StatelessWidget { + const BarScreenView({super.key}); + + @override + Widget build(BuildContext context) { + final viewModel = context.watch(); + final productsViewModel = context.watch(); + + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + tooltip: 'Manage products', + onPressed: () => context.go('/products'), + icon: const Icon(Icons.inventory_2_outlined), + ), + IconButton( + tooltip: 'Refresh', + onPressed: viewModel.load, + icon: const Icon(Icons.refresh), + ), + ], + ), + resizeToAvoidBottomInset: false, + body: Builder( + builder: (context) { + if (viewModel.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (viewModel.errorMessage != null) { + return Center(child: Text(viewModel.errorMessage!)); + } + + return Row( + children: [ + Expanded( + flex: 3, + child: _ProductGrid( + products: productsViewModel.products, + hasSelectedTab: viewModel.selectedTab != null, + onProductTap: (product) async { + if (viewModel.selectedTab == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Open or select a tab first.'), + ), + ); + return; + } + + await viewModel.addProductToSelectedTab(product); + }, + ), + ), + const VerticalDivider(width: 1), + Expanded( + flex: 1, + child: _TabPanel( + tabs: viewModel.tabs, + selectedTab: viewModel.selectedTab, + selectedTabId: viewModel.selectedTabId, + onNewTabPressed: () => _showNewTabDialog(context), + onTabSelected: viewModel.selectTab, + onItemQuantityChanged: viewModel.changeItemQuantity, + onCloseTabPressed: () => _confirmCloseTab(context), + onTabClosed: viewModel.closeTab, + ), + ), + ], + ); + }, + ), + ); + } + + Future _showNewTabDialog(BuildContext context) async { + final controller = TextEditingController(); + + final name = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Open new tab'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Customer / group name', + border: OutlineInputBorder(), + ), + onSubmitted: (value) { + Navigator.of(dialogContext).pop(value); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(controller.text); + }, + child: const Text('Open tab'), + ), + ], + ); + }, + ); + + if (name == null || name.trim().isEmpty) return; + + if (!context.mounted) return; + + await context.read().createTab(name.trim()); + } + + Future _confirmCloseTab(BuildContext context) async { + final viewModel = context.read(); + final tab = viewModel.selectedTab; + + if (tab == null) return; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text('Close ${tab.customerName}ʼs tab?'), + content: Text( + 'Current total: ${tab.formattedTotal}\n\n' + 'Payments are not handled yet, so this only marks the tab as closed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Close tab'), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + await viewModel.closeSelectedTab(); + } +} + +class _ProductGrid extends StatelessWidget { + final List products; + final bool hasSelectedTab; + final ValueChanged onProductTap; + + const _ProductGrid({ + required this.products, + required this.hasSelectedTab, + required this.onProductTap, + }); + + @override + Widget build(BuildContext context) { + if (products.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.inventory_2_outlined, size: 48), + const SizedBox(height: 12), + const Text('No products yet.'), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => context.go('/products/new'), + icon: const Icon(Icons.add), + label: const Text('Add product'), + ), + ], + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final columns = ((constraints.maxWidth / 170).floor()) + .clamp(3, 6) + .toInt(); + + return GridView.builder( + padding: const EdgeInsets.all(16), + itemCount: products.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1, + ), + itemBuilder: (context, index) { + final product = products[index]; + + return _ProductTile( + product: product, + enabled: hasSelectedTab, + onTap: () => onProductTap(product), + ); + }, + ); + }, + ); + } +} + +class _ProductTile extends StatelessWidget { + final Product product; + final bool enabled; + final VoidCallback onTap; + + const _ProductTile({ + required this.product, + required this.enabled, + required this.onTap, + }); + + bool get _hasImage { + final imagePath = product.imagePath; + + if (imagePath == null || imagePath.isEmpty) { + return false; + } + + return File(imagePath).existsSync(); + } + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + child: InkWell( + onTap: enabled ? onTap : null, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: _hasImage + ? Image.file( + File(product.imagePath!), + fit: BoxFit.scaleDown, + width: double.infinity, + height: double.infinity, + ) + : const Center( + child: Stack( + children: [ + Icon(Icons.image_not_supported_outlined, size: 42), + Text("No Image found!"), + ], + ), + ), + ), + ), + ); + } +} + +class _TabPanel extends StatefulWidget { + final List tabs; + final BarTab? selectedTab; + final String? selectedTabId; + final VoidCallback onNewTabPressed; + final ValueChanged onTabSelected; + final Future Function(TabItem item, int quantity) onItemQuantityChanged; + final VoidCallback onCloseTabPressed; + final ValueChanged onTabClosed; + + const _TabPanel({ + required this.tabs, + required this.selectedTab, + required this.selectedTabId, + required this.onNewTabPressed, + required this.onTabSelected, + required this.onItemQuantityChanged, + required this.onCloseTabPressed, + required this.onTabClosed, + }); + + @override + State<_TabPanel> createState() => _TabPanelState(); +} + +class _TabPanelState extends State<_TabPanel> { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final filteredTabs = widget.tabs.where((tab) { + return tab.customerName.toLowerCase().contains( + _searchQuery.toLowerCase(), + ); + }).toList(); + + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: widget.onNewTabPressed, + icon: const Icon(Icons.add), + label: const Text('Open tab'), + ), + ), + + const SizedBox(height: 12), + + TextField( + controller: _searchController, + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + decoration: const InputDecoration( + hintText: 'Search name...', + prefixIcon: Icon(Icons.search), + ), + ), + + const SizedBox(height: 12), + + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Open tabs', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + + const SizedBox(height: 8), + + SizedBox( + height: 200, + child: _OpenTabsList( + tabs: filteredTabs, + selectedTabId: widget.selectedTabId, + onTabSelected: widget.onTabSelected, + onTabClosed: widget.onTabClosed, + ), + ), + + const Divider(height: 24), + + Expanded( + child: widget.selectedTab == null + ? const Center(child: Text('Select or open a tab.')) + : _SelectedTabDetails( + tab: widget.selectedTab!, + onItemQuantityChanged: widget.onItemQuantityChanged, + onCloseTabPressed: widget.onCloseTabPressed, + ), + ), + ], + ), + ); + } +} + +class _OpenTabsList extends StatelessWidget { + final List tabs; + final String? selectedTabId; + final ValueChanged onTabSelected; + final ValueChanged onTabClosed; + + const _OpenTabsList({ + required this.tabs, + required this.selectedTabId, + required this.onTabSelected, + required this.onTabClosed, + }); + + @override + Widget build(BuildContext context) { + if (tabs.isEmpty) { + return const Center(child: Text('No open tabs.')); + } + + return ListView.separated( + itemCount: tabs.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final tab = tabs[index]; + final selected = tab.id == selectedTabId; + + return ClipRRect( + child: Slidable( + key: ValueKey(tab.id), + endActionPane: ActionPane( + motion: const DrawerMotion(), + extentRatio: 0.65, + children: [ + SlidableAction( + onPressed: (_) { + onTabSelected(tab.id); + // TODO: rename/edit tab + }, + icon: Icons.edit_outlined, + label: 'Edit', + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + ), + SlidableAction( + onPressed: (_) => onTabClosed(tab.id), + icon: Icons.close, + label: 'Close', + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + ], + ), + child: Card( + margin: EdgeInsets.zero, + color: selected + ? Theme.of(context).colorScheme.primaryContainer + : null, + child: ListTile( + dense: true, + selected: selected, + title: Text( + tab.customerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('${tab.itemCount} items • ${tab.formattedTotal}'), + onTap: () => onTabSelected(tab.id), + ), + ), + ), + ); + }, + ); + } +} + +class _SelectedTabDetails extends StatelessWidget { + final BarTab tab; + final Future Function(TabItem item, int quantity) onItemQuantityChanged; + final VoidCallback onCloseTabPressed; + + const _SelectedTabDetails({ + required this.tab, + required this.onItemQuantityChanged, + required this.onCloseTabPressed, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tab.customerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'Total: ${tab.formattedTotal}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Expanded( + child: tab.items.isEmpty + ? const Center(child: Text('Tap products to add them.')) + : ListView.separated( + itemCount: tab.items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = tab.items[index]; + + return _TabItemRow( + item: item, + onQuantityChanged: onItemQuantityChanged, + ); + }, + ), + ), + const Divider(height: 24), + Row( + children: [ + Expanded( + child: Text( + tab.formattedTotal, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + FilledButton( + onPressed: onCloseTabPressed, + child: const Text('Close'), + ), + ], + ), + ], + ); + } +} + +class _TabItemRow extends StatelessWidget { + final TabItem item; + final Future Function(TabItem item, int quantity) onQuantityChanged; + + const _TabItemRow({required this.item, required this.onQuantityChanged}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '${item.quantity} × ${item.formattedUnitPrice}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: () => onQuantityChanged(item, item.quantity - 1), + icon: const Icon(Icons.remove_circle_outline), + ), + Text('${item.quantity}'), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: () => onQuantityChanged(item, item.quantity + 1), + icon: const Icon(Icons.add_circle_outline), + ), + SizedBox( + width: 72, + child: Text(item.formattedLineTotal, textAlign: TextAlign.end), + ), + ], + ), + ); + } +} diff --git a/lib/views/product_form_view.dart b/lib/views/product_form_view.dart new file mode 100644 index 0000000..7238635 --- /dev/null +++ b/lib/views/product_form_view.dart @@ -0,0 +1,451 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; + +import '../models/product.dart'; +import '../viewmodels/product_list_view_model.dart'; + +class ProductFormView extends StatefulWidget { + final String? productId; + + const ProductFormView({ + super.key, + this.productId, + }); + + bool get isEditing => productId != null; + + @override + State createState() => _ProductFormViewState(); +} + +class _ProductFormViewState extends State { + final _formKey = GlobalKey(); + + final _nameController = TextEditingController(); + final _priceController = TextEditingController(); + final _stockController = TextEditingController(); + final _lowStockController = TextEditingController(); + + String? _selectedCategory; + final _categoryController = TextEditingController(); + + final _imagePicker = ImagePicker(); + + Product? _existingProduct; + String? _imagePath; + bool _isLoading = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadProductIfNeeded(); + } + + Future _loadProductIfNeeded() async { + if (!widget.isEditing) { + setState(() => _isLoading = false); + return; + } + + final viewModel = context.read(); + final product = await viewModel.getProductById(widget.productId!); + + if (!mounted) return; + + if (product == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Product not found.'), + ), + ); + + context.go('/products'); + return; + } + + _existingProduct = product; + _nameController.text = product.name; + _selectedCategory = product.category; + _priceController.text = (product.priceInCents / 100).toStringAsFixed(2); + _stockController.text = product.stockQuantity.toString(); + _lowStockController.text = product.lowStockThreshold.toString(); + _imagePath = product.imagePath; + + setState(() => _isLoading = false); + } + + @override + void dispose() { + _nameController.dispose(); + _priceController.dispose(); + _stockController.dispose(); + _lowStockController.dispose(); + + super.dispose(); + } + + Future _copyImageToAppFolder(XFile pickedFile) async { + final appDirectory = await getApplicationSupportDirectory(); + final imagesDirectory = Directory( + path.join(appDirectory.path, 'product_images'), + ); + + if (!await imagesDirectory.exists()) { + await imagesDirectory.create(recursive: true); + } + + final extension = path.extension(pickedFile.path); + final fileName = 'product_${DateTime.now().millisecondsSinceEpoch}$extension'; + final newPath = path.join(imagesDirectory.path, fileName); + + final copiedFile = await File(pickedFile.path).copy(newPath); + + return copiedFile.path; + } + + Future _pickImage() async { + final pickedFile = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, + maxWidth: 1000, + ); + + if (pickedFile == null) return; + + final copiedImagePath = await _copyImageToAppFolder(pickedFile); + + if (!mounted) return; + + setState(() { + _imagePath = copiedImagePath; + }); + } + + int _parsePriceToCents(String value) { + final normalized = value.replaceAll(',', '.'); + final euros = double.tryParse(normalized) ?? 0; + + return (euros * 100).round(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + if (_imagePath == null || _imagePath!.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Choose a product image.'), + ), + ); + return; + } + + if (_selectedCategory == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a category.'), + ), + ); + return; + } + + setState(() => _isSaving = true); + + final viewModel = context.read(); + + final name = _nameController.text.trim(); + final category = _selectedCategory!; + final priceInCents = _parsePriceToCents(_priceController.text); + final stockQuantity = int.parse(_stockController.text); + final lowStockThreshold = int.parse(_lowStockController.text); + + if (widget.isEditing) { + final updatedProduct = _existingProduct!.copyWith( + name: name, + category: category, + priceInCents: priceInCents, + stockQuantity: stockQuantity, + lowStockThreshold: lowStockThreshold, + imagePath: _imagePath, + ); + + await viewModel.updateProduct(updatedProduct); + } else { + await viewModel.addProduct( + name: name, + category: category, + priceInCents: priceInCents, + stockQuantity: stockQuantity, + lowStockThreshold: lowStockThreshold, + imagePath: _imagePath, + ); + } + + if (!mounted) return; + + setState(() => _isSaving = false); + context.go('/products'); + } + + Future _delete() async { + if (!widget.isEditing) return; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Delete product?'), + content: const Text( + 'This will remove the product from the product list.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + final viewModel = context.read(); + await viewModel.deleteProduct(widget.productId!); + + if (!mounted) return; + + context.go('/products'); + } + + Widget _buildImagePicker(BuildContext context) { + final hasImage = _imagePath != null && File(_imagePath!).existsSync(); + + return InkWell( + onTap: _pickImage, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 220, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + clipBehavior: Clip.antiAlias, + child: hasImage + ? Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(_imagePath!), + fit: BoxFit.fitHeight, + ), + Positioned( + right: 12, + bottom: 12, + child: FilledButton.icon( + onPressed: _pickImage, + icon: const Icon(Icons.image), + label: const Text('Change image'), + ), + ), + ], + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add_photo_alternate_outlined, size: 48), + const SizedBox(height: 12), + Text( + 'Choose product image', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final title = widget.isEditing ? 'Edit product' : 'Add product'; + + return Scaffold( + appBar: AppBar( + title: Text(title), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/products'), + ), + actions: [ + if (widget.isEditing) + IconButton( + onPressed: _delete, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + resizeToAvoidBottomInset: false, + body: _isLoading + ? const Center( + child: CircularProgressIndicator(), + ) + : Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + _buildImagePicker(context), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Product name', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Enter a product name.'; + } + + return null; + }, + ), + const SizedBox(height: 16), + Consumer( + builder: (context, viewModel, child) { + return LayoutBuilder( + builder: (context, constraints) { + return DropdownMenu( + width: constraints.maxWidth, + initialSelection: _selectedCategory, + enableFilter: true, + enableSearch: true, + controller: _categoryController, + requestFocusOnTap: true, + label: const Text('Category'), + hintText: 'Select a category', + dropdownMenuEntries: viewModel.categories + .map( + (category) => DropdownMenuEntry( + value: category, + label: category, + ), + ) + .toList(), + onSelected: (value) { + setState(() { + _selectedCategory = value; + }); + }, + ); + }, + ); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _priceController, + decoration: const InputDecoration( + labelText: 'Price', + prefixText: '€ ', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Enter a price.'; + } + + final normalized = value.replaceAll(',', '.'); + final price = double.tryParse(normalized); + + if (price == null || price < 0) { + return 'Enter a valid price.'; + } + + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _stockController, + decoration: const InputDecoration( + labelText: 'Current stock', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + validator: (value) { + final number = int.tryParse(value ?? ''); + + if (number == null || number < 0) { + return 'Enter a valid stock amount.'; + } + + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lowStockController, + decoration: const InputDecoration( + labelText: 'Low stock warning threshold', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + final number = int.tryParse(value ?? ''); + + if (number == null || number < 0) { + return 'Enter a valid threshold.'; + } + + return null; + }, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _isSaving ? null : _save, + icon: _isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.save), + label: Text( + widget.isEditing ? 'Save changes' : 'Add product', + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart new file mode 100644 index 0000000..8cc06db --- /dev/null +++ b/lib/views/product_list_view.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../viewmodels/product_list_view_model.dart'; + +class ProductListView extends StatelessWidget { + const ProductListView({super.key}); + + @override + Widget build(BuildContext context) { + final viewModel = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Products'), + actions: [ + IconButton( + tooltip: 'Back to bar', + onPressed: () => context.go('/bar'), + icon: const Icon(Icons.point_of_sale), + ), + ], + ), + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.go('/products/new'), + icon: const Icon(Icons.add), + label: const Text('Add product'), + ), + body: Builder( + builder: (context) { + if (viewModel.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (viewModel.errorMessage != null) { + return Center( + child: Text(viewModel.errorMessage!), + ); + } + + if (viewModel.products.isEmpty) { + return const Center( + child: Text('No products yet.'), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: viewModel.products.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final product = viewModel.products[index]; + + return Card( + child: ListTile( + leading: SizedBox( + width: 56, + height: 56, + child: Center( + child: Image.file( + File(product.imagePath!), + fit: BoxFit.contain, + ), + ), + ), + title: Text(product.name), + subtitle: Text( + '${product.category} • ${product + .formattedPrice} • Stock: ${product.stockQuantity}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go('/products/${product.id}/edit'), + ), + ); + }, + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..7fbe8d3 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,666 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + drift: + dependency: "direct main" + description: + name: drift + sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c" + url: "https://pub.dev" + source: hosted + version: "2.34.0" + drift_flutter: + dependency: "direct main" + description: + name: drift_flutter + sha256: "887fdec622174dc7eaefd0048403e34ee07cc18626ac8a7544cc3b8a4a172166" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: ea369262929d3cc6ebf9d8a00c196127966f117fe433a5e5cb47fb08008ca203 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + url: "https://pub.dev" + source: hosted + version: "17.3.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlcipher_flutter_libs: + dependency: transitive + description: + name: sqlcipher_flutter_libs + sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929" + url: "https://pub.dev" + source: hosted + version: "0.7.0+eol" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "752d9d746052359a2022f588bb979f2e7c4e0f9e4b6a1c3121f7626a1574974b" + url: "https://pub.dev" + source: hosted + version: "3.3.4" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" + url: "https://pub.dev" + source: hosted + version: "0.6.0+eol" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d88f527 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,98 @@ +name: kooltab2 +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + go_router: ^17.3.0 + provider: ^6.1.5+1 + uuid: ^4.5.3 + drift: ^2.34.0 + drift_flutter: ^0.3.0 + path_provider: ^2.1.6 + image_picker: ^1.2.3 + path: ^1.9.1 + flutter_slidable: ^4.0.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..1e95b30 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,14 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:kooltab2/main.dart'; + +void main() { +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..be3436c --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + kooltab2 + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..69c1bd2 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "kooltab2", + "short_name": "kooltab2", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..6560267 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(kooltab2 LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "kooltab2") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..ca74bcc --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "dev.brammie15" "\0" + VALUE "FileDescription", "kooltab2" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "kooltab2" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 dev.brammie15. All rights reserved." "\0" + VALUE "OriginalFilename", "kooltab2.exe" "\0" + VALUE "ProductName", "kooltab2" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..d0cff4f --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"kooltab2", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_