Merge pull request #11 from SteelDynamite/flutter-app

flutter-app
This commit is contained in:
SteelDynamite 2026-03-31 07:08:53 -07:00 committed by GitHub
commit 45c5da7f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 6563 additions and 4 deletions

View file

@ -5,6 +5,7 @@ members = [
]
exclude = [
"apps/tauri/src-tauri",
"apps/flutter/rust",
]
resolver = "2"

45
apps/flutter/.gitignore vendored Normal file
View file

@ -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

30
apps/flutter/.metadata Normal file
View file

@ -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'

17
apps/flutter/README.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -0,0 +1,3 @@
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust

172
apps/flutter/lib/main.dart Normal file
View file

@ -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<void> 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<AppState>();
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<AppState>();
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<Widget> _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),
];
}
}

View file

@ -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<AppConfigDto> getConfig() => RustLib.instance.api.crateApiGetConfig();
Future<void> initWorkspace({required String path}) =>
RustLib.instance.api.crateApiInitWorkspace(path: path);
Future<void> addWorkspace({required String name, required String path}) =>
RustLib.instance.api.crateApiAddWorkspace(name: name, path: path);
Future<void> setCurrentWorkspace({required String name}) =>
RustLib.instance.api.crateApiSetCurrentWorkspace(name: name);
Future<void> removeWorkspace({required String name}) =>
RustLib.instance.api.crateApiRemoveWorkspace(name: name);
Future<List<TaskListDto>> getLists() => RustLib.instance.api.crateApiGetLists();
Future<TaskListDto> createList({required String name}) =>
RustLib.instance.api.crateApiCreateList(name: name);
Future<void> deleteList({required String listId}) =>
RustLib.instance.api.crateApiDeleteList(listId: listId);
Future<List<TaskDto>> listTasks({required String listId}) =>
RustLib.instance.api.crateApiListTasks(listId: listId);
Future<TaskDto> createTask({
required String listId,
required String title,
required String description,
}) => RustLib.instance.api.crateApiCreateTask(
listId: listId,
title: title,
description: description,
);
Future<void> updateTask({required String listId, required TaskDto task}) =>
RustLib.instance.api.crateApiUpdateTask(listId: listId, task: task);
Future<void> deleteTask({required String listId, required String taskId}) =>
RustLib.instance.api.crateApiDeleteTask(listId: listId, taskId: taskId);
Future<TaskDto> toggleTask({required String listId, required String taskId}) =>
RustLib.instance.api.crateApiToggleTask(listId: listId, taskId: taskId);
Future<void> reorderTask({
required String listId,
required String taskId,
required int newPosition,
}) => RustLib.instance.api.crateApiReorderTask(
listId: listId,
taskId: taskId,
newPosition: newPosition,
);
Future<String> greet({required String name}) =>
RustLib.instance.api.crateApiGreet(name: name);
class AppConfigDto {
final List<WorkspaceEntry> 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;
}

File diff suppressed because it is too large Load diff

View file

@ -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<RustLibWire> {
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<TaskDto> dco_decode_list_task_dto(dynamic raw);
@protected
List<TaskListDto> dco_decode_list_task_list_dto(dynamic raw);
@protected
List<WorkspaceEntry> 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<TaskDto> sse_decode_list_task_dto(SseDeserializer deserializer);
@protected
List<TaskListDto> sse_decode_list_task_list_dto(SseDeserializer deserializer);
@protected
List<WorkspaceEntry> 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<TaskDto> self, SseSerializer serializer);
@protected
void sse_encode_list_task_list_dto(
List<TaskListDto> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_workspace_entry(
List<WorkspaceEntry> 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<T> Function<T extends ffi.NativeType>(String symbolName)
_lookup;
/// The symbols are looked up in [dynamicLibrary].
RustLibWire(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
}

View file

@ -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<RustLibWire> {
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<TaskDto> dco_decode_list_task_dto(dynamic raw);
@protected
List<TaskListDto> dco_decode_list_task_list_dto(dynamic raw);
@protected
List<WorkspaceEntry> 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<TaskDto> sse_decode_list_task_dto(SseDeserializer deserializer);
@protected
List<TaskListDto> sse_decode_list_task_list_dto(SseDeserializer deserializer);
@protected
List<WorkspaceEntry> 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<TaskDto> self, SseSerializer serializer);
@protected
void sse_encode_list_task_list_dto(
List<TaskListDto> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_workspace_entry(
List<WorkspaceEntry> 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 {}

View file

@ -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<AppState>();
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))),
),
],
),
),
),
],
),
),
),
),
),
);
}
}

View file

@ -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<SetupScreen> createState() => _SetupScreenState();
}
class _SetupScreenState extends State<SetupScreen> {
final _nameController = TextEditingController();
String? _selectedPath;
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _pickFolder() async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) setState(() => _selectedPath = result);
}
Future<void> _create() async {
final name = _nameController.text.trim();
if (name.isEmpty || _selectedPath == null) return;
await context.read<AppState>().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)),
),
),
],
),
),
),
);
}
}

View file

@ -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<TasksScreen> createState() => _TasksScreenState();
}
class _TasksScreenState extends State<TasksScreen> {
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<AppState>();
if (state.activeListId == null) return;
setState(() => _newTaskOpen = true);
}
void _closeNewTask() {
setState(() => _newTaskOpen = false);
}
Future<void> _handleCreateTask(String title, String desc, {String? dueDate}) async {
final state = context.read<AppState>();
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<void> _submitNewList() async {
final name = _newListController.text.trim();
if (name.isNotEmpty) await context.read<AppState>().createList(name);
setState(() => _addingList = false);
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
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,
),
),
],
),
),
),
);
}
}

View file

@ -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<api.TaskListDto> lists = [];
String? activeListId;
List<api.TaskDto> 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<api.TaskListDto?>().firstWhere((l) => l?.id == activeListId, orElse: () => null);
List<api.TaskDto> get pendingTasks => tasks.where((t) => t.status == 'backlog').toList();
List<api.TaskDto> 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<api.TaskDto?>().firstWhere((t) => t?.id == selectedTaskId, orElse: () => null);
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> loadTasks() async {
if (activeListId == null) return;
try {
tasks = await api.listTasks(listId: activeListId!);
} catch (e) {
error = e.toString();
}
}
Future<void> selectList(String id) async {
activeListId = id;
selectedTaskId = null;
await loadTasks();
notifyListeners();
}
Future<void> 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<void> 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<api.TaskDto?> 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<void> 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<void> 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<void> 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<void> 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();
}
}

View file

@ -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,
);
}

View file

@ -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<Widget>? 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)),
),
),
);
}
}

View file

@ -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<DateTimePicker> createState() => _DateTimePickerState();
}
class _DateTimePickerState extends State<DateTimePicker> {
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<int>(
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<int>(
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)),
),
],
],
),
);
}
}

View file

@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import 'date_time_picker.dart';
class NewTaskInput extends StatefulWidget {
final Future<void> Function(String title, String description, {String? dueDate}) onCreate;
const NewTaskInput({super.key, required this.onCreate});
@override
State<NewTaskInput> createState() => _NewTaskInputState();
}
class _NewTaskInputState extends State<NewTaskInput> {
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<void> _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,
),
),
),
),
),
],
),
),
);
}
}

View file

@ -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<TaskDetailView> createState() => _TaskDetailViewState();
}
class _TaskDetailViewState extends State<TaskDetailView> {
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<AppState>();
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<AppState>();
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<AppState>();
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)),
],
),
),
),
);
}
}

View file

@ -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<TaskItem> createState() => _TaskItemState();
}
class _TaskItemState extends State<TaskItem> {
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),
),
),
],
),
),
],
),
),
);
}
}

1
apps/flutter/linux/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
flutter/ephemeral

View file

@ -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 "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>: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()

View file

@ -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}
)

View file

@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <window_manager/window_manager_plugin.h>
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);
}

View file

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View file

@ -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 $<TARGET_FILE:${plugin}_plugin>)
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)

View file

@ -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}")

View file

@ -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);
}

View file

@ -0,0 +1,130 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#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));
}

View file

@ -0,0 +1,21 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
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_

538
apps/flutter/pubspec.lock Normal file
View file

@ -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"

24
apps/flutter/pubspec.yaml Normal file
View file

@ -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

View file

@ -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",
]

View file

@ -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"

View file

@ -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<TaskRepository>,
}
static STATE: Lazy<Mutex<AppState>> = 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<String>,
pub created_at: String,
pub updated_at: String,
pub parent_id: Option<String>,
}
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<String>,
pub last_sync: Option<String>,
}
pub struct AppConfigDto {
pub workspaces: Vec<WorkspaceEntry>,
pub current_workspace: Option<String>,
}
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<AppConfigDto, String> {
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<Vec<TaskListDto>, 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<TaskListDto, String> {
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<Vec<TaskDto>, 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<TaskDto, 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 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<TaskDto, 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 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.")
}

File diff suppressed because it is too large Load diff

View file

@ -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;