From 8bfbbd04443c0e2237c692ac96d61ef79b38a63a Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 31 Mar 2026 07:02:51 -0700 Subject: [PATCH 1/5] feat(flutter): add project scaffold and Linux runner --- apps/flutter/.gitignore | 45 ++ apps/flutter/.metadata | 30 + apps/flutter/README.md | 17 + apps/flutter/analysis_options.yaml | 28 + apps/flutter/flutter_rust_bridge.yaml | 3 + apps/flutter/linux/.gitignore | 1 + apps/flutter/linux/CMakeLists.txt | 155 +++++ apps/flutter/linux/flutter/CMakeLists.txt | 88 +++ .../flutter/generated_plugin_registrant.cc | 19 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 25 + apps/flutter/linux/runner/CMakeLists.txt | 26 + apps/flutter/linux/runner/main.cc | 6 + apps/flutter/linux/runner/my_application.cc | 130 +++++ apps/flutter/linux/runner/my_application.h | 21 + apps/flutter/pubspec.lock | 538 ++++++++++++++++++ apps/flutter/pubspec.yaml | 24 + 17 files changed, 1171 insertions(+) create mode 100644 apps/flutter/.gitignore create mode 100644 apps/flutter/.metadata create mode 100644 apps/flutter/README.md create mode 100644 apps/flutter/analysis_options.yaml create mode 100644 apps/flutter/flutter_rust_bridge.yaml create mode 100644 apps/flutter/linux/.gitignore create mode 100644 apps/flutter/linux/CMakeLists.txt create mode 100644 apps/flutter/linux/flutter/CMakeLists.txt create mode 100644 apps/flutter/linux/flutter/generated_plugin_registrant.cc create mode 100644 apps/flutter/linux/flutter/generated_plugin_registrant.h create mode 100644 apps/flutter/linux/flutter/generated_plugins.cmake create mode 100644 apps/flutter/linux/runner/CMakeLists.txt create mode 100644 apps/flutter/linux/runner/main.cc create mode 100644 apps/flutter/linux/runner/my_application.cc create mode 100644 apps/flutter/linux/runner/my_application.h create mode 100644 apps/flutter/pubspec.lock create mode 100644 apps/flutter/pubspec.yaml diff --git a/apps/flutter/.gitignore b/apps/flutter/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/flutter/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/flutter/.metadata b/apps/flutter/.metadata new file mode 100644 index 0000000..02c33e9 --- /dev/null +++ b/apps/flutter/.metadata @@ -0,0 +1,30 @@ +# 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: "db50e20168db8fee486b9abf32fc912de3bc5b6a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: linux + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + + # 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/apps/flutter/README.md b/apps/flutter/README.md new file mode 100644 index 0000000..d72b258 --- /dev/null +++ b/apps/flutter/README.md @@ -0,0 +1,17 @@ +# bevy_tasks + +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/apps/flutter/analysis_options.yaml b/apps/flutter/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/apps/flutter/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/apps/flutter/flutter_rust_bridge.yaml b/apps/flutter/flutter_rust_bridge.yaml new file mode 100644 index 0000000..c9690ba --- /dev/null +++ b/apps/flutter/flutter_rust_bridge.yaml @@ -0,0 +1,3 @@ +rust_input: crate::api +rust_root: rust/ +dart_output: lib/src/rust diff --git a/apps/flutter/linux/.gitignore b/apps/flutter/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/apps/flutter/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/apps/flutter/linux/CMakeLists.txt b/apps/flutter/linux/CMakeLists.txt new file mode 100644 index 0000000..69d23ed --- /dev/null +++ b/apps/flutter/linux/CMakeLists.txt @@ -0,0 +1,155 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner 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 "bevy_tasks") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.bevytasks.bevy_tasks") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +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() + +# 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_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Build the Rust FFI library for flutter_rust_bridge +set(RUST_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../rust") +set(RUST_LIB_NAME "libbevy_tasks_flutter.so") +if(CMAKE_BUILD_TYPE MATCHES "Debug") + set(RUST_TARGET_DIR "${RUST_DIR}/target/debug") + add_custom_command( + OUTPUT "${RUST_TARGET_DIR}/${RUST_LIB_NAME}" + COMMAND cargo build + WORKING_DIRECTORY "${RUST_DIR}" + COMMENT "Building Rust FFI library (debug)" + ) +else() + set(RUST_TARGET_DIR "${RUST_DIR}/target/release") + add_custom_command( + OUTPUT "${RUST_TARGET_DIR}/${RUST_LIB_NAME}" + COMMAND cargo build --release + WORKING_DIRECTORY "${RUST_DIR}" + COMMENT "Building Rust FFI library (release)" + ) +endif() +add_custom_target(rust_lib DEPENDS "${RUST_TARGET_DIR}/${RUST_LIB_NAME}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble rust_lib) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +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) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Install the Rust FFI library into the bundle +install(FILES "${RUST_TARGET_DIR}/${RUST_LIB_NAME}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +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. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/apps/flutter/linux/flutter/CMakeLists.txt b/apps/flutter/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/apps/flutter/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +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. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# 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/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter 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. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/apps/flutter/linux/flutter/generated_plugin_registrant.cc b/apps/flutter/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..c8f3dcc --- /dev/null +++ b/apps/flutter/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/apps/flutter/linux/flutter/generated_plugin_registrant.h b/apps/flutter/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/apps/flutter/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter/linux/flutter/generated_plugins.cmake b/apps/flutter/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..00303ac --- /dev/null +++ b/apps/flutter/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + screen_retriever_linux + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/flutter/linux/runner/CMakeLists.txt b/apps/flutter/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/apps/flutter/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +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} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# 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 application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/flutter/linux/runner/main.cc b/apps/flutter/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/apps/flutter/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/apps/flutter/linux/runner/my_application.cc b/apps/flutter/linux/runner/my_application.cc new file mode 100644 index 0000000..4aed40e --- /dev/null +++ b/apps/flutter/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Frameless transparent window + gtk_window_set_decorated(window, FALSE); + gtk_window_set_title(window, "bevy_tasks"); + gtk_window_set_default_size(window, 400, 700); + + // Enable transparency + GdkScreen* screen = gtk_widget_get_screen(GTK_WIDGET(window)); + GdkVisual* visual = gdk_screen_get_rgba_visual(screen); + if (visual != nullptr) { + gtk_widget_set_visual(GTK_WIDGET(window), visual); + } + gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + gdk_rgba_parse(&background_color, "#00000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/apps/flutter/linux/runner/my_application.h b/apps/flutter/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/apps/flutter/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/flutter/pubspec.lock b/apps/flutter/pubspec.lock new file mode 100644 index 0000000..d052664 --- /dev/null +++ b/apps/flutter/pubspec.lock @@ -0,0 +1,538 @@ +# 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" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + 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: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + 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_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + 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: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + 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" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.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" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + 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: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + 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: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + 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: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.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" + 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: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + 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: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" + url: "https://pub.dev" + source: hosted + version: "0.4.3" + 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.11.4 <4.0.0" + flutter: ">=3.38.4" diff --git a/apps/flutter/pubspec.yaml b/apps/flutter/pubspec.yaml new file mode 100644 index 0000000..8adaae7 --- /dev/null +++ b/apps/flutter/pubspec.yaml @@ -0,0 +1,24 @@ +name: bevy_tasks +description: "Bevy Tasks - local-first task management" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.11.4 + +dependencies: + flutter: + sdk: flutter + flutter_rust_bridge: 2.11.1 + provider: ^6.1.0 + window_manager: ^0.4.0 + file_picker: ^8.0.0 + google_fonts: ^6.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true From 85e616c256dfe48db1cfa62ba19f860c8a63faa6 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 31 Mar 2026 07:02:58 -0700 Subject: [PATCH 2/5] feat(flutter): add Rust bridge backend and generated bindings --- Cargo.toml | 1 + apps/flutter/lib/src/rust/api.dart | 195 ++++ apps/flutter/lib/src/rust/frb_generated.dart | 1024 +++++++++++++++++ .../lib/src/rust/frb_generated.io.dart | 190 +++ .../lib/src/rust/frb_generated.web.dart | 190 +++ apps/flutter/rust/Cargo.lock | 6 +- apps/flutter/rust/Cargo.toml | 14 + apps/flutter/rust/src/api.rs | 257 +++++ apps/flutter/rust/src/frb_generated.rs | 1018 ++++++++++++++++ apps/flutter/rust/src/lib.rs | 2 + 10 files changed, 2893 insertions(+), 4 deletions(-) create mode 100644 apps/flutter/lib/src/rust/api.dart create mode 100644 apps/flutter/lib/src/rust/frb_generated.dart create mode 100644 apps/flutter/lib/src/rust/frb_generated.io.dart create mode 100644 apps/flutter/lib/src/rust/frb_generated.web.dart create mode 100644 apps/flutter/rust/Cargo.toml create mode 100644 apps/flutter/rust/src/api.rs create mode 100644 apps/flutter/rust/src/frb_generated.rs create mode 100644 apps/flutter/rust/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 2a318a0..068f1fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ ] exclude = [ "apps/tauri/src-tauri", + "apps/flutter/rust", ] resolver = "2" diff --git a/apps/flutter/lib/src/rust/api.dart b/apps/flutter/lib/src/rust/api.dart new file mode 100644 index 0000000..957f28f --- /dev/null +++ b/apps/flutter/lib/src/rust/api.dart @@ -0,0 +1,195 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `config_to_dto`, `ensure_repo`, `task_to_dto` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `AppState` + +Future getConfig() => RustLib.instance.api.crateApiGetConfig(); + +Future initWorkspace({required String path}) => + RustLib.instance.api.crateApiInitWorkspace(path: path); + +Future addWorkspace({required String name, required String path}) => + RustLib.instance.api.crateApiAddWorkspace(name: name, path: path); + +Future setCurrentWorkspace({required String name}) => + RustLib.instance.api.crateApiSetCurrentWorkspace(name: name); + +Future removeWorkspace({required String name}) => + RustLib.instance.api.crateApiRemoveWorkspace(name: name); + +Future> getLists() => RustLib.instance.api.crateApiGetLists(); + +Future createList({required String name}) => + RustLib.instance.api.crateApiCreateList(name: name); + +Future deleteList({required String listId}) => + RustLib.instance.api.crateApiDeleteList(listId: listId); + +Future> listTasks({required String listId}) => + RustLib.instance.api.crateApiListTasks(listId: listId); + +Future createTask({ + required String listId, + required String title, + required String description, +}) => RustLib.instance.api.crateApiCreateTask( + listId: listId, + title: title, + description: description, +); + +Future updateTask({required String listId, required TaskDto task}) => + RustLib.instance.api.crateApiUpdateTask(listId: listId, task: task); + +Future deleteTask({required String listId, required String taskId}) => + RustLib.instance.api.crateApiDeleteTask(listId: listId, taskId: taskId); + +Future toggleTask({required String listId, required String taskId}) => + RustLib.instance.api.crateApiToggleTask(listId: listId, taskId: taskId); + +Future reorderTask({ + required String listId, + required String taskId, + required int newPosition, +}) => RustLib.instance.api.crateApiReorderTask( + listId: listId, + taskId: taskId, + newPosition: newPosition, +); + +Future greet({required String name}) => + RustLib.instance.api.crateApiGreet(name: name); + +class AppConfigDto { + final List workspaces; + final String? currentWorkspace; + + const AppConfigDto({required this.workspaces, this.currentWorkspace}); + + @override + int get hashCode => workspaces.hashCode ^ currentWorkspace.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppConfigDto && + runtimeType == other.runtimeType && + workspaces == other.workspaces && + currentWorkspace == other.currentWorkspace; +} + +class TaskDto { + final String id; + final String title; + final String description; + final String status; + final String? dueDate; + final String createdAt; + final String updatedAt; + final String? parentId; + + const TaskDto({ + required this.id, + required this.title, + required this.description, + required this.status, + this.dueDate, + required this.createdAt, + required this.updatedAt, + this.parentId, + }); + + @override + int get hashCode => + id.hashCode ^ + title.hashCode ^ + description.hashCode ^ + status.hashCode ^ + dueDate.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + parentId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TaskDto && + runtimeType == other.runtimeType && + id == other.id && + title == other.title && + description == other.description && + status == other.status && + dueDate == other.dueDate && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + parentId == other.parentId; +} + +class TaskListDto { + final String id; + final String title; + final String createdAt; + final String updatedAt; + final bool groupByDueDate; + + const TaskListDto({ + required this.id, + required this.title, + required this.createdAt, + required this.updatedAt, + required this.groupByDueDate, + }); + + @override + int get hashCode => + id.hashCode ^ + title.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + groupByDueDate.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TaskListDto && + runtimeType == other.runtimeType && + id == other.id && + title == other.title && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + groupByDueDate == other.groupByDueDate; +} + +class WorkspaceEntry { + final String name; + final String path; + final String? webdavUrl; + final String? lastSync; + + const WorkspaceEntry({ + required this.name, + required this.path, + this.webdavUrl, + this.lastSync, + }); + + @override + int get hashCode => + name.hashCode ^ path.hashCode ^ webdavUrl.hashCode ^ lastSync.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WorkspaceEntry && + runtimeType == other.runtimeType && + name == other.name && + path == other.path && + webdavUrl == other.webdavUrl && + lastSync == other.lastSync; +} diff --git a/apps/flutter/lib/src/rust/frb_generated.dart b/apps/flutter/lib/src/rust/frb_generated.dart new file mode 100644 index 0000000..94a1a28 --- /dev/null +++ b/apps/flutter/lib/src/rust/frb_generated.dart @@ -0,0 +1,1024 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({required RustLibApi api}) { + instance.initMockImpl(api: api); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.11.1'; + + @override + int get rustContentHash => 1511441297; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'bevy_tasks_flutter', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + ); +} + +abstract class RustLibApi extends BaseApi { + Future crateApiAddWorkspace({ + required String name, + required String path, + }); + + Future crateApiCreateList({required String name}); + + Future crateApiCreateTask({ + required String listId, + required String title, + required String description, + }); + + Future crateApiDeleteList({required String listId}); + + Future crateApiDeleteTask({ + required String listId, + required String taskId, + }); + + Future crateApiGetConfig(); + + Future> crateApiGetLists(); + + Future crateApiGreet({required String name}); + + Future crateApiInitWorkspace({required String path}); + + Future> crateApiListTasks({required String listId}); + + Future crateApiRemoveWorkspace({required String name}); + + Future crateApiReorderTask({ + required String listId, + required String taskId, + required int newPosition, + }); + + Future crateApiSetCurrentWorkspace({required String name}); + + Future crateApiToggleTask({ + required String listId, + required String taskId, + }); + + Future crateApiUpdateTask({ + required String listId, + required TaskDto task, + }); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Future crateApiAddWorkspace({ + required String name, + required String path, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + sse_encode_String(path, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 1, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiAddWorkspaceConstMeta, + argValues: [name, path], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiAddWorkspaceConstMeta => const TaskConstMeta( + debugName: "add_workspace", + argNames: ["name", "path"], + ); + + @override + Future crateApiCreateList({required String name}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 2, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_task_list_dto, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCreateListConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiCreateListConstMeta => + const TaskConstMeta(debugName: "create_list", argNames: ["name"]); + + @override + Future crateApiCreateTask({ + required String listId, + required String title, + required String description, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + sse_encode_String(title, serializer); + sse_encode_String(description, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 3, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_task_dto, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCreateTaskConstMeta, + argValues: [listId, title, description], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiCreateTaskConstMeta => const TaskConstMeta( + debugName: "create_task", + argNames: ["listId", "title", "description"], + ); + + @override + Future crateApiDeleteList({required String listId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 4, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiDeleteListConstMeta, + argValues: [listId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiDeleteListConstMeta => + const TaskConstMeta(debugName: "delete_list", argNames: ["listId"]); + + @override + Future crateApiDeleteTask({ + required String listId, + required String taskId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + sse_encode_String(taskId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 5, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiDeleteTaskConstMeta, + argValues: [listId, taskId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiDeleteTaskConstMeta => const TaskConstMeta( + debugName: "delete_task", + argNames: ["listId", "taskId"], + ); + + @override + Future crateApiGetConfig() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 6, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_app_config_dto, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiGetConfigConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiGetConfigConstMeta => + const TaskConstMeta(debugName: "get_config", argNames: []); + + @override + Future> crateApiGetLists() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 7, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_task_list_dto, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiGetListsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiGetListsConstMeta => + const TaskConstMeta(debugName: "get_lists", argNames: []); + + @override + Future crateApiGreet({required String name}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 8, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateApiGreetConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiGreetConstMeta => + const TaskConstMeta(debugName: "greet", argNames: ["name"]); + + @override + Future crateApiInitWorkspace({required String path}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(path, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 9, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiInitWorkspaceConstMeta, + argValues: [path], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiInitWorkspaceConstMeta => + const TaskConstMeta(debugName: "init_workspace", argNames: ["path"]); + + @override + Future> crateApiListTasks({required String listId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 10, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_task_dto, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiListTasksConstMeta, + argValues: [listId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiListTasksConstMeta => + const TaskConstMeta(debugName: "list_tasks", argNames: ["listId"]); + + @override + Future crateApiRemoveWorkspace({required String name}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 11, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiRemoveWorkspaceConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiRemoveWorkspaceConstMeta => + const TaskConstMeta(debugName: "remove_workspace", argNames: ["name"]); + + @override + Future crateApiReorderTask({ + required String listId, + required String taskId, + required int newPosition, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + sse_encode_String(taskId, serializer); + sse_encode_u_32(newPosition, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 12, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiReorderTaskConstMeta, + argValues: [listId, taskId, newPosition], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiReorderTaskConstMeta => const TaskConstMeta( + debugName: "reorder_task", + argNames: ["listId", "taskId", "newPosition"], + ); + + @override + Future crateApiSetCurrentWorkspace({required String name}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(name, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 13, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiSetCurrentWorkspaceConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetCurrentWorkspaceConstMeta => + const TaskConstMeta( + debugName: "set_current_workspace", + argNames: ["name"], + ); + + @override + Future crateApiToggleTask({ + required String listId, + required String taskId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + sse_encode_String(taskId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 14, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_task_dto, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiToggleTaskConstMeta, + argValues: [listId, taskId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiToggleTaskConstMeta => const TaskConstMeta( + debugName: "toggle_task", + argNames: ["listId", "taskId"], + ); + + @override + Future crateApiUpdateTask({ + required String listId, + required TaskDto task, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + sse_encode_box_autoadd_task_dto(task, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 15, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiUpdateTaskConstMeta, + argValues: [listId, task], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiUpdateTaskConstMeta => const TaskConstMeta( + debugName: "update_task", + argNames: ["listId", "task"], + ); + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + AppConfigDto dco_decode_app_config_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return AppConfigDto( + workspaces: dco_decode_list_workspace_entry(arr[0]), + currentWorkspace: dco_decode_opt_String(arr[1]), + ); + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + TaskDto dco_decode_box_autoadd_task_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_task_dto(raw); + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + List dco_decode_list_task_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_task_dto).toList(); + } + + @protected + List dco_decode_list_task_list_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_task_list_dto).toList(); + } + + @protected + List dco_decode_list_workspace_entry(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_workspace_entry).toList(); + } + + @protected + String? dco_decode_opt_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_String(raw); + } + + @protected + TaskDto dco_decode_task_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 8) + throw Exception('unexpected arr length: expect 8 but see ${arr.length}'); + return TaskDto( + id: dco_decode_String(arr[0]), + title: dco_decode_String(arr[1]), + description: dco_decode_String(arr[2]), + status: dco_decode_String(arr[3]), + dueDate: dco_decode_opt_String(arr[4]), + createdAt: dco_decode_String(arr[5]), + updatedAt: dco_decode_String(arr[6]), + parentId: dco_decode_opt_String(arr[7]), + ); + } + + @protected + TaskListDto dco_decode_task_list_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 5) + throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); + return TaskListDto( + id: dco_decode_String(arr[0]), + title: dco_decode_String(arr[1]), + createdAt: dco_decode_String(arr[2]), + updatedAt: dco_decode_String(arr[3]), + groupByDueDate: dco_decode_bool(arr[4]), + ); + } + + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + WorkspaceEntry dco_decode_workspace_entry(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return WorkspaceEntry( + name: dco_decode_String(arr[0]), + path: dco_decode_String(arr[1]), + webdavUrl: dco_decode_opt_String(arr[2]), + lastSync: dco_decode_opt_String(arr[3]), + ); + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + AppConfigDto sse_decode_app_config_dto(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_workspaces = sse_decode_list_workspace_entry(deserializer); + var var_currentWorkspace = sse_decode_opt_String(deserializer); + return AppConfigDto( + workspaces: var_workspaces, + currentWorkspace: var_currentWorkspace, + ); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + TaskDto sse_decode_box_autoadd_task_dto(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_task_dto(deserializer)); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + List sse_decode_list_task_dto(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_task_dto(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_task_list_dto( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_task_list_dto(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_workspace_entry( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_workspace_entry(deserializer)); + } + return ans_; + } + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_String(deserializer)); + } else { + return null; + } + } + + @protected + TaskDto sse_decode_task_dto(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_id = sse_decode_String(deserializer); + var var_title = sse_decode_String(deserializer); + var var_description = sse_decode_String(deserializer); + var var_status = sse_decode_String(deserializer); + var var_dueDate = sse_decode_opt_String(deserializer); + var var_createdAt = sse_decode_String(deserializer); + var var_updatedAt = sse_decode_String(deserializer); + var var_parentId = sse_decode_opt_String(deserializer); + return TaskDto( + id: var_id, + title: var_title, + description: var_description, + status: var_status, + dueDate: var_dueDate, + createdAt: var_createdAt, + updatedAt: var_updatedAt, + parentId: var_parentId, + ); + } + + @protected + TaskListDto sse_decode_task_list_dto(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_id = sse_decode_String(deserializer); + var var_title = sse_decode_String(deserializer); + var var_createdAt = sse_decode_String(deserializer); + var var_updatedAt = sse_decode_String(deserializer); + var var_groupByDueDate = sse_decode_bool(deserializer); + return TaskListDto( + id: var_id, + title: var_title, + createdAt: var_createdAt, + updatedAt: var_updatedAt, + groupByDueDate: var_groupByDueDate, + ); + } + + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + WorkspaceEntry sse_decode_workspace_entry(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_name = sse_decode_String(deserializer); + var var_path = sse_decode_String(deserializer); + var var_webdavUrl = sse_decode_opt_String(deserializer); + var var_lastSync = sse_decode_opt_String(deserializer); + return WorkspaceEntry( + name: var_name, + path: var_path, + webdavUrl: var_webdavUrl, + lastSync: var_lastSync, + ); + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_app_config_dto(AppConfigDto self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_workspace_entry(self.workspaces, serializer); + sse_encode_opt_String(self.currentWorkspace, serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_box_autoadd_task_dto(TaskDto self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_task_dto(self, serializer); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_list_task_dto(List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_task_dto(item, serializer); + } + } + + @protected + void sse_encode_list_task_list_dto( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_task_list_dto(item, serializer); + } + } + + @protected + void sse_encode_list_workspace_entry( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_workspace_entry(item, serializer); + } + } + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_String(self, serializer); + } + } + + @protected + void sse_encode_task_dto(TaskDto self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.id, serializer); + sse_encode_String(self.title, serializer); + sse_encode_String(self.description, serializer); + sse_encode_String(self.status, serializer); + sse_encode_opt_String(self.dueDate, serializer); + sse_encode_String(self.createdAt, serializer); + sse_encode_String(self.updatedAt, serializer); + sse_encode_opt_String(self.parentId, serializer); + } + + @protected + void sse_encode_task_list_dto(TaskListDto self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.id, serializer); + sse_encode_String(self.title, serializer); + sse_encode_String(self.createdAt, serializer); + sse_encode_String(self.updatedAt, serializer); + sse_encode_bool(self.groupByDueDate, serializer); + } + + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_workspace_entry( + WorkspaceEntry self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.name, serializer); + sse_encode_String(self.path, serializer); + sse_encode_opt_String(self.webdavUrl, serializer); + sse_encode_opt_String(self.lastSync, serializer); + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } +} diff --git a/apps/flutter/lib/src/rust/frb_generated.io.dart b/apps/flutter/lib/src/rust/frb_generated.io.dart new file mode 100644 index 0000000..fdc7102 --- /dev/null +++ b/apps/flutter/lib/src/rust/frb_generated.io.dart @@ -0,0 +1,190 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + AppConfigDto dco_decode_app_config_dto(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + TaskDto dco_decode_box_autoadd_task_dto(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List dco_decode_list_task_dto(dynamic raw); + + @protected + List dco_decode_list_task_list_dto(dynamic raw); + + @protected + List dco_decode_list_workspace_entry(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + + @protected + TaskDto dco_decode_task_dto(dynamic raw); + + @protected + TaskListDto dco_decode_task_list_dto(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + WorkspaceEntry dco_decode_workspace_entry(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + AppConfigDto sse_decode_app_config_dto(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + TaskDto sse_decode_box_autoadd_task_dto(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List sse_decode_list_task_dto(SseDeserializer deserializer); + + @protected + List sse_decode_list_task_list_dto(SseDeserializer deserializer); + + @protected + List sse_decode_list_workspace_entry( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + + @protected + TaskDto sse_decode_task_dto(SseDeserializer deserializer); + + @protected + TaskListDto sse_decode_task_list_dto(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + WorkspaceEntry sse_decode_workspace_entry(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_app_config_dto(AppConfigDto self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_task_dto(TaskDto self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_task_dto(List self, SseSerializer serializer); + + @protected + void sse_encode_list_task_list_dto( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_workspace_entry( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + + @protected + void sse_encode_task_dto(TaskDto self, SseSerializer serializer); + + @protected + void sse_encode_task_list_dto(TaskListDto self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_workspace_entry( + WorkspaceEntry self, + SseSerializer serializer, + ); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/apps/flutter/lib/src/rust/frb_generated.web.dart b/apps/flutter/lib/src/rust/frb_generated.web.dart new file mode 100644 index 0000000..19a95b9 --- /dev/null +++ b/apps/flutter/lib/src/rust/frb_generated.web.dart @@ -0,0 +1,190 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + AppConfigDto dco_decode_app_config_dto(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + TaskDto dco_decode_box_autoadd_task_dto(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List dco_decode_list_task_dto(dynamic raw); + + @protected + List dco_decode_list_task_list_dto(dynamic raw); + + @protected + List dco_decode_list_workspace_entry(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + + @protected + TaskDto dco_decode_task_dto(dynamic raw); + + @protected + TaskListDto dco_decode_task_list_dto(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + WorkspaceEntry dco_decode_workspace_entry(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + AppConfigDto sse_decode_app_config_dto(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + TaskDto sse_decode_box_autoadd_task_dto(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List sse_decode_list_task_dto(SseDeserializer deserializer); + + @protected + List sse_decode_list_task_list_dto(SseDeserializer deserializer); + + @protected + List sse_decode_list_workspace_entry( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + + @protected + TaskDto sse_decode_task_dto(SseDeserializer deserializer); + + @protected + TaskListDto sse_decode_task_list_dto(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + WorkspaceEntry sse_decode_workspace_entry(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_app_config_dto(AppConfigDto self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_box_autoadd_task_dto(TaskDto self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_task_dto(List self, SseSerializer serializer); + + @protected + void sse_encode_list_task_list_dto( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_workspace_entry( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + + @protected + void sse_encode_task_dto(TaskDto self, SseSerializer serializer); + + @protected + void sse_encode_task_list_dto(TaskListDto self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_workspace_entry( + WorkspaceEntry self, + SseSerializer serializer, + ); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/apps/flutter/rust/Cargo.lock b/apps/flutter/rust/Cargo.lock index 08f76e0..cd7a59c 100644 --- a/apps/flutter/rust/Cargo.lock +++ b/apps/flutter/rust/Cargo.lock @@ -126,15 +126,13 @@ dependencies = [ ] [[package]] -name = "bevy-tasks-flutter-bridge" +name = "bevy-tasks-flutter" version = "0.1.0" dependencies = [ "bevy-tasks-core", "chrono", "flutter_rust_bridge", - "serde", - "serde_json", - "tokio", + "once_cell", "uuid", ] diff --git a/apps/flutter/rust/Cargo.toml b/apps/flutter/rust/Cargo.toml new file mode 100644 index 0000000..4b60646 --- /dev/null +++ b/apps/flutter/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bevy-tasks-flutter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[dependencies] +flutter_rust_bridge = "=2.11.1" +bevy-tasks-core = { path = "../../../crates/bevy-tasks-core" } +uuid = { version = "1", features = ["serde", "v4"] } +chrono = { version = "0.4", features = ["serde"] } +once_cell = "1" diff --git a/apps/flutter/rust/src/api.rs b/apps/flutter/rust/src/api.rs new file mode 100644 index 0000000..ea22a54 --- /dev/null +++ b/apps/flutter/rust/src/api.rs @@ -0,0 +1,257 @@ +use std::path::PathBuf; +use std::sync::Mutex; + +use once_cell::sync::Lazy; +use uuid::Uuid; + +use bevy_tasks_core::{ + config::{AppConfig, WorkspaceConfig}, + models::{Task, TaskList, TaskStatus}, + repository::TaskRepository, +}; + +// ── State ─────────────────────────────────────────────────────────── + +struct AppState { + config: AppConfig, + repo: Option, +} + +static STATE: Lazy> = Lazy::new(|| { + let config_path = AppConfig::get_config_path(); + let config = AppConfig::load_from_file(&config_path).unwrap_or_default(); + Mutex::new(AppState { config, repo: None }) +}); + +fn ensure_repo(state: &mut AppState) -> Result<(), String> { + if state.repo.is_some() { + return Ok(()); + } + let (_name, ws) = state.config.get_current_workspace().map_err(|e| e.to_string())?; + let repo = TaskRepository::new(ws.path.clone()).map_err(|e| e.to_string())?; + state.repo = Some(repo); + Ok(()) +} + +// ── DTOs ──────────────────────────────────────────────────────────── + +pub struct TaskDto { + pub id: String, + pub title: String, + pub description: String, + pub status: String, + pub due_date: Option, + pub created_at: String, + pub updated_at: String, + pub parent_id: Option, +} + +pub struct TaskListDto { + pub id: String, + pub title: String, + pub created_at: String, + pub updated_at: String, + pub group_by_due_date: bool, +} + +pub struct WorkspaceEntry { + pub name: String, + pub path: String, + pub webdav_url: Option, + pub last_sync: Option, +} + +pub struct AppConfigDto { + pub workspaces: Vec, + pub current_workspace: Option, +} + +fn task_to_dto(t: &Task) -> TaskDto { + TaskDto { + id: t.id.to_string(), + title: t.title.clone(), + description: t.description.clone(), + status: match t.status { + TaskStatus::Backlog => "backlog".into(), + TaskStatus::Completed => "completed".into(), + }, + due_date: t.due_date.map(|d| d.to_rfc3339()), + created_at: t.created_at.to_rfc3339(), + updated_at: t.updated_at.to_rfc3339(), + parent_id: t.parent_id.map(|id| id.to_string()), + } +} + +fn config_to_dto(c: &AppConfig) -> AppConfigDto { + AppConfigDto { + workspaces: c + .workspaces + .iter() + .map(|(name, ws)| WorkspaceEntry { + name: name.clone(), + path: ws.path.to_string_lossy().into_owned(), + webdav_url: ws.webdav_url.clone(), + last_sync: ws.last_sync.map(|d| d.to_rfc3339()), + }) + .collect(), + current_workspace: c.current_workspace.clone(), + } +} + +// ── Config commands ───────────────────────────────────────────────── + +pub fn get_config() -> Result { + let s = STATE.lock().unwrap(); + Ok(config_to_dto(&s.config)) +} + +pub fn init_workspace(path: String) -> Result<(), String> { + TaskRepository::init(PathBuf::from(path)) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +pub fn add_workspace(name: String, path: String) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + let ws = WorkspaceConfig::new(PathBuf::from(&path)); + s.config.add_workspace(name.clone(), ws); + s.config.set_current_workspace(name).map_err(|e| e.to_string())?; + s.repo = None; + let config_path = AppConfig::get_config_path(); + s.config.save_to_file(&config_path).map_err(|e| e.to_string()) +} + +pub fn set_current_workspace(name: String) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + s.config.set_current_workspace(name).map_err(|e| e.to_string())?; + s.repo = None; + let config_path = AppConfig::get_config_path(); + s.config.save_to_file(&config_path).map_err(|e| e.to_string()) +} + +pub fn remove_workspace(name: String) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + s.config.remove_workspace(&name); + s.repo = None; + let config_path = AppConfig::get_config_path(); + s.config.save_to_file(&config_path).map_err(|e| e.to_string()) +} + +// ── List commands ─────────────────────────────────────────────────── + +pub fn get_lists() -> Result, String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let lists = s.repo.as_ref().unwrap().get_lists().map_err(|e| e.to_string())?; + Ok(lists + .iter() + .map(|l| TaskListDto { + id: l.id.to_string(), + title: l.title.clone(), + created_at: l.created_at.to_rfc3339(), + updated_at: l.updated_at.to_rfc3339(), + group_by_due_date: l.group_by_due_date, + }) + .collect()) +} + +pub fn create_list(name: String) -> Result { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let list = s.repo.as_mut().unwrap().create_list(name).map_err(|e| e.to_string())?; + Ok(TaskListDto { + id: list.id.to_string(), + title: list.title.clone(), + created_at: list.created_at.to_rfc3339(), + updated_at: list.updated_at.to_rfc3339(), + group_by_due_date: list.group_by_due_date, + }) +} + +pub fn delete_list(list_id: String) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo.as_mut().unwrap().delete_list(id).map_err(|e| e.to_string()) +} + +// ── Task commands ─────────────────────────────────────────────────── + +pub fn list_tasks(list_id: String) -> Result, String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + let tasks = s.repo.as_ref().unwrap().list_tasks(id).map_err(|e| e.to_string())?; + Ok(tasks.iter().map(|t| task_to_dto(t)).collect()) +} + +pub fn create_task(list_id: String, title: String, description: String) -> Result { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + let mut task = Task::new(title); + if !description.is_empty() { + task.description = description; + } + let created = s.repo.as_mut().unwrap().create_task(id, task).map_err(|e| e.to_string())?; + Ok(task_to_dto(&created)) +} + +pub fn update_task(list_id: String, task: TaskDto) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + let tid = Uuid::parse_str(&task.id).map_err(|e| e.to_string())?; + + let mut existing = s.repo.as_ref().unwrap().get_task(lid, tid).map_err(|e| e.to_string())?; + existing.title = task.title; + existing.description = task.description; + existing.due_date = task + .due_date + .as_deref() + .and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok()) + .map(|d| d.with_timezone(&chrono::Utc)); + + s.repo.as_mut().unwrap().update_task(lid, existing).map_err(|e| e.to_string()) +} + +pub fn delete_task(list_id: String, task_id: String) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; + s.repo.as_mut().unwrap().delete_task(lid, tid).map_err(|e| e.to_string()) +} + +pub fn toggle_task(list_id: String, task_id: String) -> Result { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; + let repo = s.repo.as_mut().unwrap(); + let mut task = repo.get_task(lid, tid).map_err(|e| e.to_string())?; + match task.status { + TaskStatus::Backlog => task.complete(), + TaskStatus::Completed => task.uncomplete(), + } + repo.update_task(lid, task.clone()).map_err(|e| e.to_string())?; + Ok(task_to_dto(&task)) +} + +pub fn reorder_task(list_id: String, task_id: String, new_position: u32) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; + s.repo + .as_mut() + .unwrap() + .reorder_task(lid, tid, new_position as usize) + .map_err(|e| e.to_string()) +} + +// ── Test function ─────────────────────────────────────────────────── + +pub fn greet(name: String) -> String { + format!("Hello, {name}! From Rust via flutter_rust_bridge.") +} diff --git a/apps/flutter/rust/src/frb_generated.rs b/apps/flutter/rust/src/frb_generated.rs new file mode 100644 index 0000000..15d71c3 --- /dev/null +++ b/apps/flutter/rust/src/frb_generated.rs @@ -0,0 +1,1018 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::needless_borrow +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1511441297; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__add_workspace_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "add_workspace", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_name = ::sse_decode(&mut deserializer); + let api_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::add_workspace(api_name, api_path)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__create_list_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "create_list", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_name = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::create_list(api_name)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__create_task_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "create_task", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + let api_title = ::sse_decode(&mut deserializer); + let api_description = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = + crate::api::create_task(api_list_id, api_title, api_description)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__delete_list_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "delete_list", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::delete_list(api_list_id)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__delete_task_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "delete_task", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + let api_task_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::delete_task(api_list_id, api_task_id)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__get_config_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_config", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::get_config()?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__get_lists_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_lists", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::get_lists()?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__greet_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "greet", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_name = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::greet(api_name))?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__init_workspace_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "init_workspace", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::init_workspace(api_path)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__list_tasks_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "list_tasks", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::list_tasks(api_list_id)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__remove_workspace_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "remove_workspace", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_name = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::remove_workspace(api_name)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__reorder_task_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "reorder_task", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + let api_task_id = ::sse_decode(&mut deserializer); + let api_new_position = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = + crate::api::reorder_task(api_list_id, api_task_id, api_new_position)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__set_current_workspace_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_current_workspace", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_name = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::set_current_workspace(api_name)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__toggle_task_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "toggle_task", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + let api_task_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::toggle_task(api_list_id, api_task_id)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__update_task_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "update_task", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + let api_task = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::update_task(api_list_id, api_task)?; + Ok(output_ok) + })()) + } + }, + ) +} + +// Section: dart2rust + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for crate::api::AppConfigDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_workspaces = >::sse_decode(deserializer); + let mut var_currentWorkspace = >::sse_decode(deserializer); + return crate::api::AppConfigDto { + workspaces: var_workspaces, + current_workspace: var_currentWorkspace, + }; + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + +impl SseDecode for crate::api::TaskDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_id = ::sse_decode(deserializer); + let mut var_title = ::sse_decode(deserializer); + let mut var_description = ::sse_decode(deserializer); + let mut var_status = ::sse_decode(deserializer); + let mut var_dueDate = >::sse_decode(deserializer); + let mut var_createdAt = ::sse_decode(deserializer); + let mut var_updatedAt = ::sse_decode(deserializer); + let mut var_parentId = >::sse_decode(deserializer); + return crate::api::TaskDto { + id: var_id, + title: var_title, + description: var_description, + status: var_status, + due_date: var_dueDate, + created_at: var_createdAt, + updated_at: var_updatedAt, + parent_id: var_parentId, + }; + } +} + +impl SseDecode for crate::api::TaskListDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_id = ::sse_decode(deserializer); + let mut var_title = ::sse_decode(deserializer); + let mut var_createdAt = ::sse_decode(deserializer); + let mut var_updatedAt = ::sse_decode(deserializer); + let mut var_groupByDueDate = ::sse_decode(deserializer); + return crate::api::TaskListDto { + id: var_id, + title: var_title, + created_at: var_createdAt, + updated_at: var_updatedAt, + group_by_due_date: var_groupByDueDate, + }; + } +} + +impl SseDecode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u32::().unwrap() + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +impl SseDecode for crate::api::WorkspaceEntry { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_name = ::sse_decode(deserializer); + let mut var_path = ::sse_decode(deserializer); + let mut var_webdavUrl = >::sse_decode(deserializer); + let mut var_lastSync = >::sse_decode(deserializer); + return crate::api::WorkspaceEntry { + name: var_name, + path: var_path, + webdav_url: var_webdavUrl, + last_sync: var_lastSync, + }; + } +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__add_workspace_impl(port, ptr, rust_vec_len, data_len), + 2 => wire__crate__api__create_list_impl(port, ptr, rust_vec_len, data_len), + 3 => wire__crate__api__create_task_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__delete_list_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__delete_task_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__get_config_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__get_lists_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__greet_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__init_workspace_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__list_tasks_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__remove_workspace_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__reorder_task_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__set_current_workspace_impl(port, ptr, rust_vec_len, data_len), + 14 => wire__crate__api__toggle_task_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__update_task_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + _ => unreachable!(), + } +} + +// Section: rust2dart + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::AppConfigDto { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.workspaces.into_into_dart().into_dart(), + self.current_workspace.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::AppConfigDto {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::AppConfigDto { + fn into_into_dart(self) -> crate::api::AppConfigDto { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::TaskDto { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.id.into_into_dart().into_dart(), + self.title.into_into_dart().into_dart(), + self.description.into_into_dart().into_dart(), + self.status.into_into_dart().into_dart(), + self.due_date.into_into_dart().into_dart(), + self.created_at.into_into_dart().into_dart(), + self.updated_at.into_into_dart().into_dart(), + self.parent_id.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::TaskDto {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::TaskDto { + fn into_into_dart(self) -> crate::api::TaskDto { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::TaskListDto { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.id.into_into_dart().into_dart(), + self.title.into_into_dart().into_dart(), + self.created_at.into_into_dart().into_dart(), + self.updated_at.into_into_dart().into_dart(), + self.group_by_due_date.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::TaskListDto {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::TaskListDto { + fn into_into_dart(self) -> crate::api::TaskListDto { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::WorkspaceEntry { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.name.into_into_dart().into_dart(), + self.path.into_into_dart().into_dart(), + self.webdav_url.into_into_dart().into_dart(), + self.last_sync.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::WorkspaceEntry {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::WorkspaceEntry { + fn into_into_dart(self) -> crate::api::WorkspaceEntry { + self + } +} + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for crate::api::AppConfigDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.workspaces, serializer); + >::sse_encode(self.current_workspace, serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + +impl SseEncode for crate::api::TaskDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.id, serializer); + ::sse_encode(self.title, serializer); + ::sse_encode(self.description, serializer); + ::sse_encode(self.status, serializer); + >::sse_encode(self.due_date, serializer); + ::sse_encode(self.created_at, serializer); + ::sse_encode(self.updated_at, serializer); + >::sse_encode(self.parent_id, serializer); + } +} + +impl SseEncode for crate::api::TaskListDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.id, serializer); + ::sse_encode(self.title, serializer); + ::sse_encode(self.created_at, serializer); + ::sse_encode(self.updated_at, serializer); + ::sse_encode(self.group_by_due_date, serializer); + } +} + +impl SseEncode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u32::(self).unwrap(); + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +impl SseEncode for crate::api::WorkspaceEntry { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.name, serializer); + ::sse_encode(self.path, serializer); + >::sse_encode(self.webdav_url, serializer); + >::sse_encode(self.last_sync, serializer); + } +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.11.1. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.11.1. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/apps/flutter/rust/src/lib.rs b/apps/flutter/rust/src/lib.rs new file mode 100644 index 0000000..25448a4 --- /dev/null +++ b/apps/flutter/rust/src/lib.rs @@ -0,0 +1,2 @@ +mod frb_generated; /* AUTO INJECTED BY flutter_rust_bridge. This line may not be accurate, and you can change it according to your needs. */ +pub mod api; From b236892203b7eeef1417bd6464c26f46aa2de461 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 31 Mar 2026 07:03:02 -0700 Subject: [PATCH 3/5] feat(flutter): add app shell, theme, and state management --- apps/flutter/lib/main.dart | 172 ++++++++++++++++ apps/flutter/lib/src/state/app_state.dart | 233 ++++++++++++++++++++++ apps/flutter/lib/src/theme.dart | 46 +++++ 3 files changed, 451 insertions(+) create mode 100644 apps/flutter/lib/main.dart create mode 100644 apps/flutter/lib/src/state/app_state.dart create mode 100644 apps/flutter/lib/src/theme.dart diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart new file mode 100644 index 0000000..af9e77b --- /dev/null +++ b/apps/flutter/lib/main.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:window_manager/window_manager.dart'; +import 'src/rust/frb_generated.dart'; +import 'src/theme.dart'; +import 'src/state/app_state.dart'; +import 'src/screens/setup_screen.dart'; +import 'src/screens/tasks_screen.dart'; +import 'src/screens/settings_screen.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await RustLib.init(); + + await windowManager.ensureInitialized(); + await windowManager.waitUntilReadyToShow( + const WindowOptions( + size: Size(400, 700), + minimumSize: Size(320, 500), + titleBarStyle: TitleBarStyle.hidden, + ), + () async { + await windowManager.setBackgroundColor(Colors.transparent); + await windowManager.setResizable(true); + await windowManager.show(); + await windowManager.focus(); + }, + ); + + runApp( + ChangeNotifierProvider( + create: (_) => AppState()..loadConfig(), + child: const BevyTasksApp(), + ), + ); +} + +class BevyTasksApp extends StatelessWidget { + const BevyTasksApp({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + return MaterialApp( + title: 'Bevy Tasks', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: state.darkMode ? ThemeMode.dark : ThemeMode.light, + home: const AppShell(), + ); + } +} + +class AppShell extends StatelessWidget { + const AppShell({super.key}); + + static const _edge = 8.0; + + SystemMouseCursor _cursorFor(ResizeEdge? edge) => switch (edge) { + ResizeEdge.top || ResizeEdge.bottom => SystemMouseCursors.resizeUpDown, + ResizeEdge.left || ResizeEdge.right => SystemMouseCursors.resizeLeftRight, + ResizeEdge.topLeft || ResizeEdge.bottomRight => SystemMouseCursors.resizeUpLeftDownRight, + ResizeEdge.topRight || ResizeEdge.bottomLeft => SystemMouseCursors.resizeUpRightDownLeft, + _ => SystemMouseCursors.basic, + }; + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final isDark = Theme.of(context).brightness == Brightness.dark; + return Scaffold( + backgroundColor: Colors.transparent, + body: LayoutBuilder(builder: (context, constraints) { + return MouseRegion( + cursor: SystemMouseCursors.basic, + hitTestBehavior: HitTestBehavior.translucent, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerHover: (event) { + // Update cursor based on edge proximity (handled by nested MouseRegion below) + }, + child: Stack( + children: [ + // Resize hit zones (in the 8px padding area) + ..._buildResizeZones(constraints), + // Main content with padding + Padding( + padding: const EdgeInsets.all(_edge), + child: Container( + decoration: BoxDecoration( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? Colors.white.withValues(alpha: 0.15) : Colors.black.withValues(alpha: 0.15), + ), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 2)), + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + if (state.screen == 'setup') + const SetupScreen() + else + const TasksScreen(), + if (state.error != null) + Positioned( + top: 0, left: 0, right: 0, + child: Material( + color: AppTheme.danger, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text(state.error!, style: const TextStyle(color: Colors.white, fontSize: 13)), + ), + GestureDetector( + onTap: state.clearError, + child: const Text('✕', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ), + if (state.screen == 'settings') + const SettingsScreen(), + ], + ), + ), + ), + ], + ), + ), + ); + }), + ); + } + + List _buildResizeZones(BoxConstraints constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + Widget zone(ResizeEdge edge, {required double left, required double top, required double width, required double height}) { + return Positioned( + left: left, top: top, width: width, height: height, + child: MouseRegion( + cursor: _cursorFor(edge), + child: GestureDetector( + onPanStart: (_) => windowManager.startResizing(edge), + ), + ), + ); + } + return [ + // Corners (larger hit area) + zone(ResizeEdge.topLeft, left: 0, top: 0, width: _edge * 2, height: _edge * 2), + zone(ResizeEdge.topRight, left: w - _edge * 2, top: 0, width: _edge * 2, height: _edge * 2), + zone(ResizeEdge.bottomLeft, left: 0, top: h - _edge * 2, width: _edge * 2, height: _edge * 2), + zone(ResizeEdge.bottomRight, left: w - _edge * 2, top: h - _edge * 2, width: _edge * 2, height: _edge * 2), + // Edges + zone(ResizeEdge.top, left: _edge * 2, top: 0, width: w - _edge * 4, height: _edge), + zone(ResizeEdge.bottom, left: _edge * 2, top: h - _edge, width: w - _edge * 4, height: _edge), + zone(ResizeEdge.left, left: 0, top: _edge * 2, width: _edge, height: h - _edge * 4), + zone(ResizeEdge.right, left: w - _edge, top: _edge * 2, width: _edge, height: h - _edge * 4), + ]; + } +} diff --git a/apps/flutter/lib/src/state/app_state.dart b/apps/flutter/lib/src/state/app_state.dart new file mode 100644 index 0000000..9c5ba65 --- /dev/null +++ b/apps/flutter/lib/src/state/app_state.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import '../rust/api.dart' as api; + +class AppState extends ChangeNotifier { + String screen = 'setup'; + api.AppConfigDto? config; + List lists = []; + String? activeListId; + List tasks = []; + bool darkMode = true; + bool syncing = false; + String? error; + + // Selected task for detail view + String? selectedTaskId; + + api.TaskListDto? get activeList => + activeListId == null ? null : lists.cast().firstWhere((l) => l?.id == activeListId, orElse: () => null); + + List get pendingTasks => tasks.where((t) => t.status == 'backlog').toList(); + List get completedTasks => tasks.where((t) => t.status == 'completed').toList(); + + bool get hasWorkspace => + config != null && config!.currentWorkspace != null && config!.workspaces.isNotEmpty; + + api.TaskDto? get selectedTask => + selectedTaskId == null ? null : tasks.cast().firstWhere((t) => t?.id == selectedTaskId, orElse: () => null); + + Future loadConfig() async { + try { + config = await api.getConfig(); + if (hasWorkspace) { + screen = 'tasks'; + await loadLists(); + } else { + screen = 'setup'; + } + } catch (e) { + config = const api.AppConfigDto(workspaces: [], currentWorkspace: null); + screen = 'setup'; + } + notifyListeners(); + } + + Future addWorkspace(String name, String path) async { + try { + await api.initWorkspace(path: path); + await api.addWorkspace(name: name, path: path); + config = await api.getConfig(); + await loadLists(); + screen = 'tasks'; + error = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future switchWorkspace(String name) async { + try { + await api.setCurrentWorkspace(name: name); + config = await api.getConfig(); + activeListId = null; + await loadLists(); + error = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future removeWorkspace(String name) async { + try { + await api.removeWorkspace(name: name); + config = await api.getConfig(); + if (!hasWorkspace) { + screen = 'setup'; + lists = []; + tasks = []; + activeListId = null; + } + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future loadLists() async { + try { + lists = await api.getLists(); + if (lists.isNotEmpty && activeListId == null) { + activeListId = lists[0].id; + } + if (activeListId != null) await loadTasks(); + } catch (e) { + error = e.toString(); + } + } + + Future loadTasks() async { + if (activeListId == null) return; + try { + tasks = await api.listTasks(listId: activeListId!); + } catch (e) { + error = e.toString(); + } + } + + Future selectList(String id) async { + activeListId = id; + selectedTaskId = null; + await loadTasks(); + notifyListeners(); + } + + Future createList(String name) async { + try { + final list = await api.createList(name: name); + lists = [...lists, list]; + activeListId = list.id; + tasks = []; + error = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future deleteList(String id) async { + try { + await api.deleteList(listId: id); + lists = lists.where((l) => l.id != id).toList(); + if (activeListId == id) { + activeListId = lists.isNotEmpty ? lists[0].id : null; + if (activeListId != null) { + await loadTasks(); + } else { + tasks = []; + } + } + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future createTask(String title, String description) async { + if (activeListId == null) return null; + try { + final task = await api.createTask(listId: activeListId!, title: title, description: description); + tasks = [...tasks, task]; + error = null; + notifyListeners(); + return task; + } catch (e) { + error = e.toString(); + notifyListeners(); + return null; + } + } + + Future toggleTask(String taskId) async { + if (activeListId == null) return; + try { + final updated = await api.toggleTask(listId: activeListId!, taskId: taskId); + if (updated.status == 'backlog') { + tasks = [updated, ...tasks.where((t) => t.id != taskId)]; + try { + await api.reorderTask(listId: activeListId!, taskId: taskId, newPosition: 0); + } catch (_) {} + } else { + tasks = tasks.map((t) => t.id == taskId ? updated : t).toList(); + } + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future updateTask(api.TaskDto task) async { + if (activeListId == null) return; + try { + await api.updateTask(listId: activeListId!, task: task); + tasks = tasks.map((t) => t.id == task.id ? task : t).toList(); + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future reorderTask(String taskId, int newPosition) async { + if (activeListId == null) return; + try { + await api.reorderTask(listId: activeListId!, taskId: taskId, newPosition: newPosition); + await loadTasks(); + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future deleteTask(String taskId) async { + if (activeListId == null) return; + try { + await api.deleteTask(listId: activeListId!, taskId: taskId); + tasks = tasks.where((t) => t.id != taskId).toList(); + if (selectedTaskId == taskId) selectedTaskId = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + void selectTask(String? taskId) { + selectedTaskId = taskId; + notifyListeners(); + } + + void toggleDarkMode() { + darkMode = !darkMode; + notifyListeners(); + } + + void setScreen(String s) { + screen = s; + notifyListeners(); + } + + void clearError() { + error = null; + notifyListeners(); + } +} diff --git a/apps/flutter/lib/src/theme.dart b/apps/flutter/lib/src/theme.dart new file mode 100644 index 0000000..eecd8d0 --- /dev/null +++ b/apps/flutter/lib/src/theme.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class AppTheme { + static const primary = Color(0xFF2D87B8); + static const primaryHover = Color(0xFF2474A0); + static const danger = Color(0xFFEF4444); + + static const surfaceLight = Color(0xFFFFFFFF); + static const cardLight = Color(0xFFF9FAFB); + static const textLight = Color(0xFF1F2937); + static const textSecondaryLight = Color(0xFF6B7280); + static const borderLight = Color(0xFFE5E7EB); + + static const surfaceDark = Color(0xFF242424); + static const cardDark = Color(0xFF303030); + static const textDark = Color(0xFFE5E7EB); + static const textSecondaryDark = Color(0xFF9CA3AF); + static const borderDark = Color(0xFF3D3D3D); + + static ThemeData light() => ThemeData( + brightness: Brightness.light, + colorScheme: const ColorScheme.light( + primary: primary, + surface: surfaceLight, + error: danger, + ), + scaffoldBackgroundColor: surfaceLight, + textTheme: GoogleFonts.notoSansTextTheme(ThemeData.light().textTheme), + dividerColor: borderLight, + cardColor: cardLight, + ); + + static ThemeData dark() => ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: primary, + surface: surfaceDark, + error: danger, + ), + scaffoldBackgroundColor: surfaceDark, + textTheme: GoogleFonts.notoSansTextTheme(ThemeData.dark().textTheme), + dividerColor: borderDark, + cardColor: cardDark, + ); +} From 0fc5066f97028faee7841d570e13a32133c6002b Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 31 Mar 2026 07:03:07 -0700 Subject: [PATCH 4/5] =?UTF-8?q?feat(flutter):=20add=20screens=20=E2=80=94?= =?UTF-8?q?=20setup,=20tasks,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/screens/settings_screen.dart | 154 ++++ .../flutter/lib/src/screens/setup_screen.dart | 155 ++++ .../flutter/lib/src/screens/tasks_screen.dart | 683 ++++++++++++++++++ 3 files changed, 992 insertions(+) create mode 100644 apps/flutter/lib/src/screens/settings_screen.dart create mode 100644 apps/flutter/lib/src/screens/setup_screen.dart create mode 100644 apps/flutter/lib/src/screens/tasks_screen.dart diff --git a/apps/flutter/lib/src/screens/settings_screen.dart b/apps/flutter/lib/src/screens/settings_screen.dart new file mode 100644 index 0000000..20572c5 --- /dev/null +++ b/apps/flutter/lib/src/screens/settings_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../state/app_state.dart'; +import '../theme.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final isDark = Theme.of(context).brightness == Brightness.dark; + return GestureDetector( + onTap: () => state.setScreen('tasks'), + child: Container( + color: Colors.black.withValues(alpha: 0.5), + padding: EdgeInsets.symmetric( + horizontal: MediaQuery.of(context).size.width * 0.04, + vertical: MediaQuery.of(context).size.height * 0.04, + ), + child: GestureDetector( + onTap: () {}, + child: AnimatedScale( + scale: 1.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Container( + decoration: BoxDecoration( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.7), blurRadius: 60, offset: const Offset(0, 25)), + BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 20, offset: const Offset(0, 10)), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + // Header (matching Tauri: text-lg font-bold, border-b, px-4 py-3) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + child: Row( + children: [ + const Text('Settings', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + const Spacer(), + GestureDetector( + onTap: () => state.setScreen('tasks'), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.close, size: 20, + color: isDark ? AppTheme.textDark : AppTheme.textLight), + ), + ), + ], + ), + ), + // Scrollable content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // WebDAV Sync section (matching Tauri order: sync first) + Text('WEBDAV SYNC', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5), + )), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + child: Text( + 'WebDAV sync not yet available in Flutter build', + style: TextStyle(fontSize: 13, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + ), + ), + const SizedBox(height: 24), + // Appearance section + Text('APPEARANCE', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5), + )), + const SizedBox(height: 12), + // Dark mode toggle in bordered card (matching Tauri) + GestureDetector( + onTap: () => state.toggleDarkMode(), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + child: Row( + children: [ + const Text('Dark mode', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const Spacer(), + // Toggle switch (matching Tauri: h-6 w-11) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 44, + height: 24, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: state.darkMode ? AppTheme.primary : (isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB)), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + alignment: state.darkMode ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 20, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 32), + Center( + child: Text('Flutter + Rust', style: TextStyle(fontSize: 12, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.3))), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/screens/setup_screen.dart b/apps/flutter/lib/src/screens/setup_screen.dart new file mode 100644 index 0000000..9da106b --- /dev/null +++ b/apps/flutter/lib/src/screens/setup_screen.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:provider/provider.dart'; +import '../state/app_state.dart'; +import '../theme.dart'; + +class SetupScreen extends StatefulWidget { + const SetupScreen({super.key}); + + @override + State createState() => _SetupScreenState(); +} + +class _SetupScreenState extends State { + final _nameController = TextEditingController(); + String? _selectedPath; + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _pickFolder() async { + final result = await FilePicker.platform.getDirectoryPath(); + if (result != null) setState(() => _selectedPath = result); + } + + Future _create() async { + final name = _nameController.text.trim(); + if (name.isEmpty || _selectedPath == null) return; + await context.read().addWorkspace(name, _selectedPath!); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Container( + constraints: const BoxConstraints(maxWidth: 384), + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: isDark ? AppTheme.cardDark : AppTheme.cardLight, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 10, offset: const Offset(0, 4)), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Bevy Tasks', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, + color: isDark ? AppTheme.textDark : AppTheme.textLight)), + const SizedBox(height: 4), + Text('Create or open a workspace to get started.', + style: TextStyle(fontSize: 14, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)), + const SizedBox(height: 24), + // Workspace name label + input + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('Workspace name', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, + color: isDark ? AppTheme.textDark : AppTheme.textLight)), + ), + TextField( + controller: _nameController, + style: TextStyle(fontSize: 14, color: isDark ? AppTheme.textDark : AppTheme.textLight), + decoration: InputDecoration( + hintText: 'My Tasks', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppTheme.primary), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + filled: false, + ), + ), + const SizedBox(height: 16), + // Folder label + picker row + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('Folder', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, + color: isDark ? AppTheme.textDark : AppTheme.textLight)), + ), + Row( + children: [ + Expanded( + child: TextField( + readOnly: true, + style: TextStyle(fontSize: 14, color: isDark ? AppTheme.textDark : AppTheme.textLight), + controller: TextEditingController(text: _selectedPath ?? ''), + decoration: InputDecoration( + hintText: 'Select a folder\u2026', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + filled: false, + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _pickFolder, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('Browse', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ), + ], + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: (_nameController.text.trim().isNotEmpty && _selectedPath != null) ? _create : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + disabledBackgroundColor: AppTheme.primary.withValues(alpha: 0.4), + disabledForegroundColor: Colors.white.withValues(alpha: 0.6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: const Text('Create Workspace', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/screens/tasks_screen.dart b/apps/flutter/lib/src/screens/tasks_screen.dart new file mode 100644 index 0000000..56dbe43 --- /dev/null +++ b/apps/flutter/lib/src/screens/tasks_screen.dart @@ -0,0 +1,683 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../rust/api.dart' as api; +import '../state/app_state.dart'; +import '../theme.dart'; +import '../widgets/custom_title_bar.dart'; +import '../widgets/task_item.dart'; +import '../widgets/task_detail_view.dart'; +import '../widgets/new_task_input.dart'; + +class TasksScreen extends StatefulWidget { + const TasksScreen({super.key}); + + @override + State createState() => _TasksScreenState(); +} + +class _TasksScreenState extends State { + bool _drawerOpen = false; + bool _showCompleted = false; + bool _completedVisible = false; + bool _addingList = false; + bool _workspaceSwitcherOpen = false; + bool _newTaskOpen = false; + final _newListController = TextEditingController(); + final _newListFocus = FocusNode(); + + @override + void dispose() { + _newListController.dispose(); + _newListFocus.dispose(); + super.dispose(); + } + + void _toggleDrawer() => setState(() { + _drawerOpen = !_drawerOpen; + if (!_drawerOpen) _workspaceSwitcherOpen = false; + }); + + void _closeDrawer() => setState(() { + _drawerOpen = false; + _workspaceSwitcherOpen = false; + _addingList = false; + }); + + void _showNewTask() { + final state = context.read(); + if (state.activeListId == null) return; + setState(() => _newTaskOpen = true); + } + + void _closeNewTask() { + setState(() => _newTaskOpen = false); + } + + Future _handleCreateTask(String title, String desc, {String? dueDate}) async { + final state = context.read(); + final task = await state.createTask(title, desc); + if (task != null && dueDate != null) { + await state.updateTask(api.TaskDto( + id: task.id, title: task.title, description: task.description, + status: task.status, dueDate: dueDate, + createdAt: task.createdAt, updatedAt: task.updatedAt, parentId: task.parentId, + )); + } + _closeNewTask(); + } + + void _startAddingList() { + setState(() { + _addingList = true; + _newListController.clear(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _newListFocus.requestFocus()); + } + + Future _submitNewList() async { + final name = _newListController.text.trim(); + if (name.isNotEmpty) await context.read().createList(name); + setState(() => _addingList = false); + } + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return LayoutBuilder(builder: (context, constraints) { + final width = constraints.maxWidth; + final drawerWidth = width * 0.8; + final hasDetail = state.selectedTask != null; + + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + // Sliding container: drawer + main + detail + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + left: _drawerOpen ? 0.0 : -drawerWidth, + top: 0, + bottom: 0, + width: drawerWidth + width, + child: Row( + children: [ + SizedBox(width: drawerWidth, child: _buildDrawer(state, isDark)), + SizedBox( + width: width, + child: _buildMainWithDetail(state, isDark, width), + ), + ], + ), + ), + // FAB button (centered, 56px, hidden when drawer/detail/newTask open) + if (!_drawerOpen && !hasDetail && !_newTaskOpen && state.activeListId != null) + Positioned( + bottom: 24, + left: 0, + right: 0, + child: Center( + child: SizedBox( + width: 56, + height: 56, + child: FloatingActionButton( + onPressed: _showNewTask, + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + elevation: 6, + shape: const CircleBorder(), + child: const Icon(Icons.add, size: 28), + ), + ), + ), + ), + // New task overlay (animated, inside app bounds) + Positioned.fill( + child: IgnorePointer( + ignoring: !_newTaskOpen, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + opacity: _newTaskOpen ? 1.0 : 0.0, + child: GestureDetector( + onTap: _closeNewTask, + child: Container( + color: Colors.black.withValues(alpha: 0.4), + alignment: Alignment.bottomCenter, + child: GestureDetector( + onTap: () {}, + child: _newTaskOpen + ? NewTaskInput(onCreate: _handleCreateTask) + : const SizedBox.shrink(), + ), + ), + ), + ), + ), + ), + ], + ); + }); + } + + Widget _buildMainWithDetail(AppState state, bool isDark, double totalWidth) { + final hasDetail = state.selectedTask != null; + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + ClipRect( + child: OverflowBox( + maxWidth: totalWidth * 2, + alignment: Alignment.centerLeft, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + transform: Matrix4.translationValues(hasDetail ? -totalWidth : 0, 0, 0), + width: totalWidth * 2, + child: Row( + children: [ + SizedBox(width: totalWidth, child: _buildMain(state, isDark)), + SizedBox( + width: totalWidth, + child: Container( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + child: hasDetail + ? TaskDetailView(task: state.selectedTask!) + : const SizedBox.shrink(), + ), + ), + ], + ), + ), + ), + ), + // Dim overlay when drawer is open (animated fade) + Positioned.fill( + child: IgnorePointer( + ignoring: !_drawerOpen, + child: GestureDetector( + onTap: _closeDrawer, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + opacity: _drawerOpen ? 1.0 : 0.0, + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 24, + offset: const Offset(8, 0), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildDrawer(AppState state, bool isDark) { + return Container( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + child: Column( + children: [ + // Header: workspace switcher (matching Tauri) + GestureDetector( + onPanStart: (_) {}, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5), + ), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => setState(() => _workspaceSwitcherOpen = !_workspaceSwitcherOpen), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + state.config?.currentWorkspace ?? 'Workspace', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + AnimatedRotation( + turns: _workspaceSwitcherOpen ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon(Icons.expand_more, size: 14, + color: isDark ? AppTheme.textDark : AppTheme.textLight), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + // Workspace dropdown (appears below header) + if (_workspaceSwitcherOpen && state.config != null) + Container( + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + border: Border( + bottom: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5), + ), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + children: [ + for (final ws in state.config!.workspaces) + GestureDetector( + onTap: () { + state.switchWorkspace(ws.name); + setState(() => _workspaceSwitcherOpen = false); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + if (ws.name == state.config?.currentWorkspace) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.check, size: 16, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ws.name, + style: TextStyle(fontSize: 14, + fontWeight: ws.name == state.config?.currentWorkspace ? FontWeight.w700 : FontWeight.normal), + overflow: TextOverflow.ellipsis), + Text(ws.path, + style: TextStyle(fontSize: 12, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + overflow: TextOverflow.ellipsis), + ], + ), + ), + ], + ), + ), + ), + ), + // Add workspace + Container( + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: GestureDetector( + onTap: () { + setState(() => _workspaceSwitcherOpen = false); + state.setScreen('setup'); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Text('+ Add workspace', + style: TextStyle(fontSize: 14, color: AppTheme.primary)), + ), + ), + ), + ], + ), + ), + // List items + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + for (final list in state.lists) + _ListTile( + list: list, + isActive: list.id == state.activeListId, + onTap: () { + state.selectList(list.id); + _closeDrawer(); + }, + onDelete: () => state.deleteList(list.id), + ), + // New list button / input + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: _addingList + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _newListController, + focusNode: _newListFocus, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: 'List name', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppTheme.primary), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + ), + onSubmitted: (_) => _submitNewList(), + onTapOutside: (_) => setState(() => _addingList = false), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _submitNewList, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('Add', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ), + ], + ), + ) + : GestureDetector( + onTap: _startAddingList, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Text('+ New list', style: TextStyle(fontSize: 14, color: AppTheme.primary)), + ), + ), + ), + ], + ), + ), + // Footer: Settings button (matching Tauri) + GestureDetector( + onTap: () => state.setScreen('settings'), + child: Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Icon(Icons.settings, size: 18, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), + const SizedBox(width: 8), + Text('Settings', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5))), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildMain(AppState state, bool isDark) { + return Column( + children: [ + // Title bar with menu button + centered title + close + CustomTitleBar( + leading: GestureDetector( + onTap: _toggleDrawer, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.menu, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.6)), + ), + ), + title: state.activeList?.title ?? 'Tasks', + centerTitle: true, + ), + // Task list + Expanded( + child: state.lists.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('No lists yet', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.6))), + const SizedBox(height: 4), + Text('Tap the list name above to create one', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))), + ], + ), + ) + : state.activeList == null + ? Center( + child: Text('Select a list', style: TextStyle( + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))), + ) + : _buildTaskList(state, isDark), + ), + ], + ); + } + + Widget _buildTaskList(AppState state, bool isDark) { + if (state.pendingTasks.isEmpty && state.completedTasks.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text('No tasks. Add one below.', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))), + ), + ); + } + return ReorderableListView( + buildDefaultDragHandles: false, + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex--; + final task = state.pendingTasks[oldIndex]; + state.reorderTask(task.id, newIndex); + }, + proxyDecorator: (child, index, animation) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) => Material( + elevation: 4, + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + child: child, + ), + child: child, + ); + }, + footer: _buildCompletedSection(state, isDark), + children: [ + for (var i = 0; i < state.pendingTasks.length; i++) + ReorderableDragStartListener( + key: ValueKey(state.pendingTasks[i].id), + index: i, + child: TaskItem( + task: state.pendingTasks[i], + onToggle: () => state.toggleTask(state.pendingTasks[i].id), + onTap: () => state.selectTask(state.pendingTasks[i].id), + ), + ), + ], + ); + } + + Widget? _buildCompletedSection(AppState state, bool isDark) { + if (state.completedTasks.isEmpty) return null; + return Column( + children: [ + const SizedBox(height: 16), + // Completed header (matching Tauri: full-width, border-top, text left, chevron right) + GestureDetector( + onTap: () { + setState(() { + if (_showCompleted) { + _showCompleted = false; + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) setState(() => _completedVisible = false); + }); + } else { + _completedVisible = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _showCompleted = true); + }); + } + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Completed (${state.completedTasks.length})', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight, + ), + ), + ), + AnimatedRotation( + turns: _showCompleted ? 0.25 : 0, + duration: const Duration(milliseconds: 200), + child: Icon(Icons.chevron_right, size: 16, + color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + ), + ], + ), + ), + ), + if (_completedVisible) + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _showCompleted ? 1.0 : 0.0, + child: AnimatedSlide( + duration: const Duration(milliseconds: 300), + offset: _showCompleted ? Offset.zero : const Offset(0, -0.05), + child: Column( + children: [ + for (final task in state.completedTasks) + TaskItem( + task: task, + onToggle: () => state.toggleTask(task.id), + onTap: () => state.selectTask(task.id), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ListTile extends StatefulWidget { + final dynamic list; + final bool isActive; + final VoidCallback onTap; + final VoidCallback onDelete; + + const _ListTile({required this.list, required this.isActive, required this.onTap, required this.onDelete}); + + @override + State<_ListTile> createState() => _ListTileState(); +} + +class _ListTileState extends State<_ListTile> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: widget.onTap, + onSecondaryTapUp: (details) { + showMenu( + context: context, + position: RelativeRect.fromLTRB(details.globalPosition.dx, details.globalPosition.dy, 0, 0), + items: [ + PopupMenuItem( + onTap: widget.onDelete, + child: const Text('Delete', style: TextStyle(color: AppTheme.danger, fontSize: 13)), + ), + ], + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + decoration: BoxDecoration( + color: _hovering && !widget.isActive + ? (isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05)) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + if (widget.isActive) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.check, size: 16, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), + ), + Expanded( + child: Text( + widget.list.title, + style: TextStyle( + fontSize: 14, + fontWeight: widget.isActive ? FontWeight.w700 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} From 3b48f5fd86403a797432c89f89b6b19e6a901eb5 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 31 Mar 2026 07:03:12 -0700 Subject: [PATCH 5/5] =?UTF-8?q?feat(flutter):=20add=20widgets=20=E2=80=94?= =?UTF-8?q?=20title=20bar,=20task=20item,=20detail=20view,=20new=20task,?= =?UTF-8?q?=20date=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/widgets/custom_title_bar.dart | 101 +++++ .../lib/src/widgets/date_time_picker.dart | 218 +++++++++++ .../lib/src/widgets/new_task_input.dart | 200 ++++++++++ .../lib/src/widgets/task_detail_view.dart | 358 ++++++++++++++++++ apps/flutter/lib/src/widgets/task_item.dart | 179 +++++++++ 5 files changed, 1056 insertions(+) create mode 100644 apps/flutter/lib/src/widgets/custom_title_bar.dart create mode 100644 apps/flutter/lib/src/widgets/date_time_picker.dart create mode 100644 apps/flutter/lib/src/widgets/new_task_input.dart create mode 100644 apps/flutter/lib/src/widgets/task_detail_view.dart create mode 100644 apps/flutter/lib/src/widgets/task_item.dart diff --git a/apps/flutter/lib/src/widgets/custom_title_bar.dart b/apps/flutter/lib/src/widgets/custom_title_bar.dart new file mode 100644 index 0000000..f2e834c --- /dev/null +++ b/apps/flutter/lib/src/widgets/custom_title_bar.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; +import '../theme.dart'; + +class CustomTitleBar extends StatelessWidget { + final Widget? leading; + final String? title; + final bool centerTitle; + final List? actions; + final bool showClose; + + const CustomTitleBar({super.key, this.leading, this.title, this.centerTitle = false, this.actions, this.showClose = true}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return GestureDetector( + onPanStart: (_) => windowManager.startDragging(), + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark ? AppTheme.borderDark : AppTheme.borderLight, + width: 0.5, + ), + ), + ), + child: Row( + children: [ + if (leading != null) leading!, + if (title != null) + Expanded( + child: centerTitle + ? Center( + child: Text( + title!, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ) + : Text( + title!, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ) + else + const Expanded(child: SizedBox.shrink()), + if (actions != null) ...actions!, + if (showClose) ...[ + const SizedBox(width: 4), + _TitleBarButton( + icon: Icons.close, + onPressed: () => windowManager.close(), + hoverColor: AppTheme.danger, + ), + ], + ], + ), + ), + ); + } +} + +class _TitleBarButton extends StatefulWidget { + final IconData icon; + final VoidCallback onPressed; + final Color hoverColor; + + const _TitleBarButton({required this.icon, required this.onPressed, required this.hoverColor}); + + @override + State<_TitleBarButton> createState() => _TitleBarButtonState(); +} + +class _TitleBarButtonState extends State<_TitleBarButton> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: widget.onPressed, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: _hovering ? Colors.black.withValues(alpha: 0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(widget.icon, size: 14, + color: _hovering ? widget.hoverColor : Theme.of(context).iconTheme.color?.withValues(alpha: 0.5)), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/date_time_picker.dart b/apps/flutter/lib/src/widgets/date_time_picker.dart new file mode 100644 index 0000000..3443487 --- /dev/null +++ b/apps/flutter/lib/src/widgets/date_time_picker.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import '../theme.dart'; + +class DateTimePicker extends StatefulWidget { + final DateTime? initialDate; + final void Function(DateTime date) onDone; + final VoidCallback onClear; + + const DateTimePicker({super.key, this.initialDate, required this.onDone, required this.onClear}); + + @override + State createState() => _DateTimePickerState(); +} + +class _DateTimePickerState extends State { + late DateTime _viewMonth; + DateTime? _selected; + bool _showTime = false; + int _hour = 12; + int _minute = 0; + + @override + void initState() { + super.initState(); + _selected = widget.initialDate; + _viewMonth = widget.initialDate ?? DateTime.now(); + if (widget.initialDate != null) { + _hour = widget.initialDate!.hour; + _minute = widget.initialDate!.minute; + _showTime = _hour != 0 || _minute != 0; + } + } + + void _prevMonth() => setState(() => _viewMonth = DateTime(_viewMonth.year, _viewMonth.month - 1)); + void _nextMonth() => setState(() => _viewMonth = DateTime(_viewMonth.year, _viewMonth.month + 1)); + + void _done() { + if (_selected == null) return; + final result = _showTime + ? DateTime(_selected!.year, _selected!.month, _selected!.day, _hour, _minute) + : DateTime(_selected!.year, _selected!.month, _selected!.day); + widget.onDone(result); + Navigator.of(context).pop(); + } + + void _clear() { + widget.onClear(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final firstDay = DateTime(_viewMonth.year, _viewMonth.month, 1); + final lastDay = DateTime(_viewMonth.year, _viewMonth.month + 1, 0); + final startWeekday = firstDay.weekday; // 1=Mon + const dayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + const Text('Date & Time', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const Spacer(), + GestureDetector( + onTap: _done, + child: const Text('Done', style: TextStyle(fontSize: 14, color: AppTheme.primary, fontWeight: FontWeight.w500)), + ), + ], + ), + const SizedBox(height: 16), + // Month navigation + Row( + children: [ + GestureDetector(onTap: _prevMonth, child: const Icon(Icons.chevron_left, size: 20)), + Expanded( + child: Center( + child: Text('${months[_viewMonth.month - 1]} ${_viewMonth.year}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ), + ), + GestureDetector(onTap: _nextMonth, child: const Icon(Icons.chevron_right, size: 20)), + ], + ), + const SizedBox(height: 12), + // Day names + Row( + children: [ + for (final name in dayNames) + Expanded( + child: Center( + child: Text(name, style: TextStyle(fontSize: 11, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)), + ), + ), + ], + ), + const SizedBox(height: 4), + // Calendar grid + ...List.generate(6, (week) { + return Row( + children: List.generate(7, (dow) { + final dayIndex = week * 7 + dow - (startWeekday - 1); + if (dayIndex < 0 || dayIndex >= lastDay.day) return const Expanded(child: SizedBox(height: 32)); + final day = dayIndex + 1; + final date = DateTime(_viewMonth.year, _viewMonth.month, day); + final isToday = date == today; + final isSelected = _selected != null && date.year == _selected!.year && date.month == _selected!.month && date.day == _selected!.day; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selected = date), + child: Container( + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? AppTheme.primary : Colors.transparent, + ), + child: Text( + '$day', + style: TextStyle( + fontSize: 13, + fontWeight: isToday ? FontWeight.w700 : FontWeight.normal, + color: isSelected ? Colors.white : (isToday ? AppTheme.primary : null), + ), + ), + ), + ), + ); + }), + ); + }), + const SizedBox(height: 8), + // Time toggle + Container( + padding: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + child: Column( + children: [ + GestureDetector( + onTap: () => setState(() => _showTime = !_showTime), + child: Row( + children: [ + Icon(Icons.access_time, size: 16, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + const SizedBox(width: 8), + Text(_showTime ? 'Time' : 'Set time', + style: TextStyle(fontSize: 13, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)), + const Spacer(), + Icon(_showTime ? Icons.expand_less : Icons.expand_more, size: 18, + color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + ], + ), + ), + if (_showTime) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Hour + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButton( + value: _hour, + underline: const SizedBox.shrink(), + isDense: true, + style: TextStyle(fontSize: 14, color: isDark ? AppTheme.textDark : AppTheme.textLight), + items: List.generate(24, (i) => DropdownMenuItem(value: i, child: Text(i.toString().padLeft(2, '0')))), + onChanged: (v) => setState(() => _hour = v!), + ), + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text(':', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))), + // Minute + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButton( + value: _minute, + underline: const SizedBox.shrink(), + isDense: true, + style: TextStyle(fontSize: 14, color: isDark ? AppTheme.textDark : AppTheme.textLight), + items: List.generate(12, (i) => i * 5).map((m) => DropdownMenuItem(value: m, child: Text(m.toString().padLeft(2, '0')))).toList(), + onChanged: (v) => setState(() => _minute = v!), + ), + ), + ], + ), + ], + ], + ), + ), + // Clear button + if (widget.initialDate != null) ...[ + const SizedBox(height: 12), + GestureDetector( + onTap: _clear, + child: const Text('Clear date', style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ), + ], + ], + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/new_task_input.dart b/apps/flutter/lib/src/widgets/new_task_input.dart new file mode 100644 index 0000000..96e1daa --- /dev/null +++ b/apps/flutter/lib/src/widgets/new_task_input.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import '../theme.dart'; +import 'date_time_picker.dart'; + +class NewTaskInput extends StatefulWidget { + final Future Function(String title, String description, {String? dueDate}) onCreate; + + const NewTaskInput({super.key, required this.onCreate}); + + @override + State createState() => _NewTaskInputState(); +} + +class _NewTaskInputState extends State { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _titleFocus = FocusNode(); + DateTime? _selectedDate; + + @override + void initState() { + super.initState(); + _titleFocus.requestFocus(); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _titleFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + final title = _titleController.text.trim(); + if (title.isEmpty) return; + await widget.onCreate(title, _descController.text.trim(), dueDate: _selectedDate?.toUtc().toIso8601String()); + } + + void _pickDate() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => DateTimePicker( + initialDate: _selectedDate, + onDone: (date) => setState(() => _selectedDate = date), + onClear: () => setState(() => _selectedDate = null), + ), + ); + } + + String _formatDateChip(DateTime d) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final taskDate = DateTime(d.year, d.month, d.day); + final dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + final day = dayNames[d.weekday % 7]; + final pad = (int n) => n.toString().padLeft(2, '0'); + final hasTime = d.hour != 0 || d.minute != 0; + final timePart = hasTime ? ', ${pad(d.hour)}:${pad(d.minute)}' : ''; + if (taskDate == today) return 'Today$timePart'; + return '$day, ${pad(d.day)}/${pad(d.month)}$timePart'; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( + decoration: BoxDecoration( + color: isDark ? AppTheme.cardDark : AppTheme.surfaceLight, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: EdgeInsets.only( + left: 16, right: 16, top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title input + TextField( + controller: _titleController, + focusNode: _titleFocus, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + decoration: InputDecoration( + hintText: 'Task title', + hintStyle: TextStyle( + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.3)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 16), + // Description with icon (matching Tauri) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.subject, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _descController, + style: const TextStyle(fontSize: 14), + maxLines: 3, + decoration: InputDecoration( + hintText: 'Add details', + hintStyle: TextStyle( + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + // Date/time with icon (matching Tauri) + Row( + children: [ + Icon(Icons.access_time, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + const SizedBox(width: 12), + if (_selectedDate != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _pickDate, + child: Text( + _formatDateChip(_selectedDate!), + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () => setState(() => _selectedDate = null), + child: Icon(Icons.close, size: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + ], + ), + ) + else + GestureDetector( + onTap: _pickDate, + child: Text('Add date/time', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))), + ), + ], + ), + const SizedBox(height: 16), + // Save button (centered, matching Tauri) + Container( + padding: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + child: SizedBox( + width: double.infinity, + child: GestureDetector( + onTap: _submit, + child: Text('Save', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: _titleController.text.trim().isNotEmpty + ? AppTheme.primary + : AppTheme.primary.withValues(alpha: 0.3), + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/task_detail_view.dart b/apps/flutter/lib/src/widgets/task_detail_view.dart new file mode 100644 index 0000000..43940be --- /dev/null +++ b/apps/flutter/lib/src/widgets/task_detail_view.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; +import '../rust/api.dart' as api; +import '../state/app_state.dart'; +import '../theme.dart'; +import 'package:provider/provider.dart'; +import 'date_time_picker.dart'; + +class TaskDetailView extends StatefulWidget { + final api.TaskDto task; + + const TaskDetailView({super.key, required this.task}); + + @override + State createState() => _TaskDetailViewState(); +} + +class _TaskDetailViewState extends State { + late TextEditingController _titleController; + late TextEditingController _descController; + Timer? _debounce; + bool _showMenu = false; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.task.title); + _descController = TextEditingController(text: widget.task.description); + } + + @override + void didUpdateWidget(TaskDetailView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.task.id != widget.task.id) { + _titleController.text = widget.task.title; + _descController.text = widget.task.description; + _showMenu = false; + } + } + + @override + void dispose() { + _debounce?.cancel(); + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + + void _scheduleUpdate({String? dueDate}) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 400), () { + final state = context.read(); + state.updateTask(api.TaskDto( + id: widget.task.id, + title: _titleController.text, + description: _descController.text, + status: widget.task.status, + dueDate: dueDate ?? widget.task.dueDate, + createdAt: widget.task.createdAt, + updatedAt: widget.task.updatedAt, + parentId: widget.task.parentId, + )); + }); + } + + void _updateDueDate(String? dueDate) { + final state = context.read(); + state.updateTask(api.TaskDto( + id: widget.task.id, + title: _titleController.text, + description: _descController.text, + status: widget.task.status, + dueDate: dueDate, + createdAt: widget.task.createdAt, + updatedAt: widget.task.updatedAt, + parentId: widget.task.parentId, + )); + } + + void _editDate() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => DateTimePicker( + initialDate: widget.task.dueDate != null ? DateTime.tryParse(widget.task.dueDate!) : null, + onDone: (date) => _updateDueDate(date.toUtc().toIso8601String()), + onClear: () => _updateDueDate(null), + ), + ); + } + + String _formatDateChip(String iso) { + final d = DateTime.tryParse(iso); + if (d == null) return iso; + final local = d.toLocal(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final taskDate = DateTime(local.year, local.month, local.day); + final dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + final day = dayNames[local.weekday % 7]; + final pad = (int n) => n.toString().padLeft(2, '0'); + final hasTime = local.hour != 0 || local.minute != 0; + final timePart = hasTime ? ', ${pad(local.hour)}:${pad(local.minute)}' : ''; + if (taskDate == today) return 'Today$timePart'; + return '$day, ${pad(local.day)}/${pad(local.month)}$timePart'; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final state = context.read(); + final isCompleted = widget.task.status == 'completed'; + return Column( + children: [ + // Header (just back button, matching Tauri) + GestureDetector( + onPanStart: (_) => windowManager.startDragging(), + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark ? AppTheme.borderDark : AppTheme.borderLight, + width: 0.5, + ), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => state.selectTask(null), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.arrow_back, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.6)), + ), + ), + ], + ), + ), + ), + // Content + Expanded( + child: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.only(left: 16, right: 16, top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + TextField( + controller: _titleController, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Task title', + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (_) => _scheduleUpdate(), + ), + const SizedBox(height: 16), + // Description with icon (matching Tauri) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.subject, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _descController, + style: const TextStyle(fontSize: 14), + maxLines: null, + minLines: 3, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'Add details', + hintStyle: TextStyle( + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (_) => _scheduleUpdate(), + ), + ), + ], + ), + const SizedBox(height: 16), + // Date/time with icon (matching Tauri) + Row( + children: [ + Icon(Icons.access_time, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + const SizedBox(width: 12), + if (widget.task.dueDate != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _editDate, + child: Text( + _formatDateChip(widget.task.dueDate!), + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () => _updateDueDate(null), + child: Icon(Icons.close, size: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + ], + ), + ) + else + GestureDetector( + onTap: _editDate, + child: Text('Add date/time', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))), + ), + ], + ), + ], + ), + ), + // Click-off backdrop to close kebab menu + if (_showMenu) + Positioned.fill( + child: GestureDetector( + onTap: () => setState(() => _showMenu = false), + behavior: HitTestBehavior.opaque, + child: const SizedBox.expand(), + ), + ), + // Kebab menu (absolute positioned in content, matching Tauri) + Positioned( + right: 12, + top: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => setState(() => _showMenu = !_showMenu), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.more_vert, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), + ), + ), + if (_showMenu) + Container( + margin: const EdgeInsets.only(top: 4), + constraints: const BoxConstraints(minWidth: 200), + decoration: BoxDecoration( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _KebabMenuItem( + icon: isCompleted ? Icons.close : Icons.check, + label: isCompleted ? 'Restore task' : 'Mark as completed', + onTap: () { + setState(() => _showMenu = false); + state.toggleTask(widget.task.id); + state.selectTask(null); + }, + ), + _KebabMenuItem( + icon: Icons.delete_outline, + label: 'Delete', + color: AppTheme.danger, + onTap: () { + setState(() => _showMenu = false); + state.deleteTask(widget.task.id); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +class _KebabMenuItem extends StatefulWidget { + final IconData icon; + final String label; + final Color? color; + final VoidCallback onTap; + + const _KebabMenuItem({required this.icon, required this.label, this.color, required this.onTap}); + + @override + State<_KebabMenuItem> createState() => _KebabMenuItemState(); +} + +class _KebabMenuItemState extends State<_KebabMenuItem> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: widget.onTap, + child: Container( + color: _hovering + ? (isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05)) + : Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(widget.icon, size: 16, color: widget.color), + const SizedBox(width: 8), + Text(widget.label, style: TextStyle(color: widget.color, fontSize: 14)), + ], + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/task_item.dart b/apps/flutter/lib/src/widgets/task_item.dart new file mode 100644 index 0000000..44432cb --- /dev/null +++ b/apps/flutter/lib/src/widgets/task_item.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import '../rust/api.dart' as api; +import '../theme.dart'; + +class TaskItem extends StatefulWidget { + final api.TaskDto task; + final VoidCallback onToggle; + final VoidCallback onTap; + + const TaskItem({super.key, required this.task, required this.onToggle, required this.onTap}); + + @override + State createState() => _TaskItemState(); +} + +class _TaskItemState extends State { + bool _hovering = false; + double _swipeOffset = 0; + + String _formatDueDate(String isoDate) { + final date = DateTime.tryParse(isoDate); + if (date == null) return ''; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final taskDate = DateTime(date.year, date.month, date.day); + final diff = taskDate.difference(today).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Tomorrow'; + return date.toLocal().toIso8601String().substring(5, 10).replaceAll('-', '/'); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final isCompleted = widget.task.status == 'completed'; + final canSwipeLeft = !isCompleted; + final canSwipeRight = isCompleted; + + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: () { + setState(() => _hovering = false); + widget.onTap(); + }, + onHorizontalDragUpdate: (details) { + setState(() { + _swipeOffset += details.delta.dx; + if (canSwipeLeft) _swipeOffset = _swipeOffset.clamp(-150.0, 0.0); + else if (canSwipeRight) _swipeOffset = _swipeOffset.clamp(0.0, 150.0); + else _swipeOffset = 0; + }); + }, + onHorizontalDragEnd: (details) { + if (_swipeOffset.abs() > 100) widget.onToggle(); + setState(() => _swipeOffset = 0); + }, + child: Stack( + children: [ + // Swipe background + if (_swipeOffset != 0) + Positioned.fill( + child: Container( + color: AppTheme.primary, + alignment: _swipeOffset < 0 ? Alignment.centerRight : Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _swipeOffset < 0 ? 'Complete' : 'Undo', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ), + // Task content + Container( + transform: Matrix4.translationValues(_swipeOffset, 0, 0), + color: _hovering + ? (isDark ? Colors.white.withValues(alpha: 0.05) : Colors.black.withValues(alpha: 0.05)) + : (isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Checkbox with expanded touch target + GestureDetector( + onTap: widget.onToggle, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(2), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted ? AppTheme.primary : Colors.transparent, + border: Border.all( + color: isCompleted ? AppTheme.primary : (isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF)), + width: 2, + ), + ), + child: isCompleted + ? const Icon(Icons.check, size: 12, color: Colors.white) + : null, + ), + ), + ), + const SizedBox(width: 12), + // Content column (title, description, due date below) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.task.title, + style: TextStyle( + fontSize: 14, + fontWeight: isCompleted ? FontWeight.normal : FontWeight.w500, + decoration: isCompleted ? TextDecoration.lineThrough : null, + color: isCompleted + ? (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5) + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.task.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + widget.task.description, + style: TextStyle( + fontSize: 12, + color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.4), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Due date badge (below title/description, matching Tauri) + if (widget.task.dueDate != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + _formatDueDate(widget.task.dueDate!), + style: TextStyle( + fontSize: 12, + color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5), + ), + ), + ), + ), + ], + ), + ), + // Chevron (show on hover only) + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _hovering ? 0.3 : 0, + child: Padding( + padding: const EdgeInsets.only(left: 4, top: 4), + child: Icon(Icons.chevron_right, size: 16, + color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +}