commit
45c5da7f47
|
|
@ -5,6 +5,7 @@ members = [
|
||||||
]
|
]
|
||||||
exclude = [
|
exclude = [
|
||||||
"apps/tauri/src-tauri",
|
"apps/tauri/src-tauri",
|
||||||
|
"apps/flutter/rust",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|
|
||||||
45
apps/flutter/.gitignore
vendored
Normal file
45
apps/flutter/.gitignore
vendored
Normal 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
30
apps/flutter/.metadata
Normal 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
17
apps/flutter/README.md
Normal 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.
|
||||||
28
apps/flutter/analysis_options.yaml
Normal file
28
apps/flutter/analysis_options.yaml
Normal 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
|
||||||
3
apps/flutter/flutter_rust_bridge.yaml
Normal file
3
apps/flutter/flutter_rust_bridge.yaml
Normal 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
172
apps/flutter/lib/main.dart
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
195
apps/flutter/lib/src/rust/api.dart
Normal file
195
apps/flutter/lib/src/rust/api.dart
Normal 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;
|
||||||
|
}
|
||||||
1024
apps/flutter/lib/src/rust/frb_generated.dart
Normal file
1024
apps/flutter/lib/src/rust/frb_generated.dart
Normal file
File diff suppressed because it is too large
Load diff
190
apps/flutter/lib/src/rust/frb_generated.io.dart
Normal file
190
apps/flutter/lib/src/rust/frb_generated.io.dart
Normal 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;
|
||||||
|
}
|
||||||
190
apps/flutter/lib/src/rust/frb_generated.web.dart
Normal file
190
apps/flutter/lib/src/rust/frb_generated.web.dart
Normal 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 {}
|
||||||
154
apps/flutter/lib/src/screens/settings_screen.dart
Normal file
154
apps/flutter/lib/src/screens/settings_screen.dart
Normal 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))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
apps/flutter/lib/src/screens/setup_screen.dart
Normal file
155
apps/flutter/lib/src/screens/setup_screen.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
683
apps/flutter/lib/src/screens/tasks_screen.dart
Normal file
683
apps/flutter/lib/src/screens/tasks_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
apps/flutter/lib/src/state/app_state.dart
Normal file
233
apps/flutter/lib/src/state/app_state.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/flutter/lib/src/theme.dart
Normal file
46
apps/flutter/lib/src/theme.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
101
apps/flutter/lib/src/widgets/custom_title_bar.dart
Normal file
101
apps/flutter/lib/src/widgets/custom_title_bar.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
apps/flutter/lib/src/widgets/date_time_picker.dart
Normal file
218
apps/flutter/lib/src/widgets/date_time_picker.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
apps/flutter/lib/src/widgets/new_task_input.dart
Normal file
200
apps/flutter/lib/src/widgets/new_task_input.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
358
apps/flutter/lib/src/widgets/task_detail_view.dart
Normal file
358
apps/flutter/lib/src/widgets/task_detail_view.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
apps/flutter/lib/src/widgets/task_item.dart
Normal file
179
apps/flutter/lib/src/widgets/task_item.dart
Normal 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
1
apps/flutter/linux/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
flutter/ephemeral
|
||||||
155
apps/flutter/linux/CMakeLists.txt
Normal file
155
apps/flutter/linux/CMakeLists.txt
Normal 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()
|
||||||
88
apps/flutter/linux/flutter/CMakeLists.txt
Normal file
88
apps/flutter/linux/flutter/CMakeLists.txt
Normal 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}
|
||||||
|
)
|
||||||
19
apps/flutter/linux/flutter/generated_plugin_registrant.cc
Normal file
19
apps/flutter/linux/flutter/generated_plugin_registrant.cc
Normal 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);
|
||||||
|
}
|
||||||
15
apps/flutter/linux/flutter/generated_plugin_registrant.h
Normal file
15
apps/flutter/linux/flutter/generated_plugin_registrant.h
Normal 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_
|
||||||
25
apps/flutter/linux/flutter/generated_plugins.cmake
Normal file
25
apps/flutter/linux/flutter/generated_plugins.cmake
Normal 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)
|
||||||
26
apps/flutter/linux/runner/CMakeLists.txt
Normal file
26
apps/flutter/linux/runner/CMakeLists.txt
Normal 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}")
|
||||||
6
apps/flutter/linux/runner/main.cc
Normal file
6
apps/flutter/linux/runner/main.cc
Normal 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);
|
||||||
|
}
|
||||||
130
apps/flutter/linux/runner/my_application.cc
Normal file
130
apps/flutter/linux/runner/my_application.cc
Normal 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));
|
||||||
|
}
|
||||||
21
apps/flutter/linux/runner/my_application.h
Normal file
21
apps/flutter/linux/runner/my_application.h
Normal 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
538
apps/flutter/pubspec.lock
Normal 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
24
apps/flutter/pubspec.yaml
Normal 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
|
||||||
6
apps/flutter/rust/Cargo.lock
generated
6
apps/flutter/rust/Cargo.lock
generated
|
|
@ -126,15 +126,13 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bevy-tasks-flutter-bridge"
|
name = "bevy-tasks-flutter"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy-tasks-core",
|
"bevy-tasks-core",
|
||||||
"chrono",
|
"chrono",
|
||||||
"flutter_rust_bridge",
|
"flutter_rust_bridge",
|
||||||
"serde",
|
"once_cell",
|
||||||
"serde_json",
|
|
||||||
"tokio",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
14
apps/flutter/rust/Cargo.toml
Normal file
14
apps/flutter/rust/Cargo.toml
Normal 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"
|
||||||
257
apps/flutter/rust/src/api.rs
Normal file
257
apps/flutter/rust/src/api.rs
Normal 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.")
|
||||||
|
}
|
||||||
1018
apps/flutter/rust/src/frb_generated.rs
Normal file
1018
apps/flutter/rust/src/frb_generated.rs
Normal file
File diff suppressed because it is too large
Load diff
2
apps/flutter/rust/src/lib.rs
Normal file
2
apps/flutter/rust/src/lib.rs
Normal 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;
|
||||||
Loading…
Reference in a new issue