tauri and flutter guis
This commit is contained in:
parent
087617b47f
commit
ec17d6274f
|
|
@ -4,6 +4,10 @@ members = [
|
||||||
"crates/bevy-tasks-cli",
|
"crates/bevy-tasks-cli",
|
||||||
"crates/bevy-tasks-gui",
|
"crates/bevy-tasks-gui",
|
||||||
]
|
]
|
||||||
|
exclude = [
|
||||||
|
"apps/tauri/src-tauri",
|
||||||
|
"apps/flutter/rust",
|
||||||
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
|
||||||
48
apps/flutter/.gitignore
vendored
Normal file
48
apps/flutter/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Rust bridge build artifacts
|
||||||
|
rust/target/
|
||||||
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: "ff37bef603469fb030f2b72995ab929ccfc227f0"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
- platform: windows
|
||||||
|
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
|
||||||
|
# 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.
|
||||||
6
apps/flutter/analysis_options.yaml
Normal file
6
apps/flutter/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_const_constructors: true
|
||||||
|
prefer_const_declarations: true
|
||||||
4
apps/flutter/flutter_rust_bridge.yaml
Normal file
4
apps/flutter/flutter_rust_bridge.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
rust_input: crate::api
|
||||||
|
rust_root: rust/
|
||||||
|
dart_output: lib/src/rust/
|
||||||
|
c_output: windows/runner/bridge_generated.h
|
||||||
48
apps/flutter/lib/main.dart
Normal file
48
apps/flutter/lib/main.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'providers/app_provider.dart';
|
||||||
|
import 'screens/setup_screen.dart';
|
||||||
|
import 'screens/tasks_screen.dart';
|
||||||
|
import 'screens/settings_screen.dart';
|
||||||
|
import 'src/rust/frb_generated.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await RustLib.init();
|
||||||
|
runApp(const BevyTasksApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class BevyTasksApp extends StatelessWidget {
|
||||||
|
const BevyTasksApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ChangeNotifierProvider(
|
||||||
|
create: (_) => AppProvider()..init(),
|
||||||
|
child: Consumer<AppProvider>(
|
||||||
|
builder: (context, app, _) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Bevy Tasks',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
darkTheme: AppTheme.dark(),
|
||||||
|
themeMode: app.darkMode ? ThemeMode.dark : ThemeMode.light,
|
||||||
|
home: _buildScreen(app),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScreen(AppProvider app) {
|
||||||
|
switch (app.screen) {
|
||||||
|
case AppScreen.setup:
|
||||||
|
return const SetupScreen();
|
||||||
|
case AppScreen.tasks:
|
||||||
|
return const TasksScreen();
|
||||||
|
case AppScreen.settings:
|
||||||
|
return const SettingsScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
257
apps/flutter/lib/providers/app_provider.dart
Normal file
257
apps/flutter/lib/providers/app_provider.dart
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../src/rust/api.dart' as api;
|
||||||
|
|
||||||
|
enum AppScreen { setup, tasks, settings }
|
||||||
|
|
||||||
|
class AppProvider extends ChangeNotifier {
|
||||||
|
AppScreen _screen = AppScreen.setup;
|
||||||
|
bool _darkMode = false;
|
||||||
|
bool _syncing = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
List<api.BridgeWorkspace> _workspaces = [];
|
||||||
|
String? _currentWorkspace;
|
||||||
|
|
||||||
|
List<api.BridgeTaskList> _lists = [];
|
||||||
|
String? _activeListId;
|
||||||
|
List<api.BridgeTask> _tasks = [];
|
||||||
|
|
||||||
|
// ── Getters ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
AppScreen get screen => _screen;
|
||||||
|
bool get darkMode => _darkMode;
|
||||||
|
bool get syncing => _syncing;
|
||||||
|
String? get error => _error;
|
||||||
|
List<api.BridgeWorkspace> get workspaces => _workspaces;
|
||||||
|
String? get currentWorkspace => _currentWorkspace;
|
||||||
|
List<api.BridgeTaskList> get lists => _lists;
|
||||||
|
String? get activeListId => _activeListId;
|
||||||
|
api.BridgeTaskList? get activeList =>
|
||||||
|
_activeListId == null ? null : _lists.where((l) => l.id == _activeListId).firstOrNull;
|
||||||
|
List<api.BridgeTask> get tasks => _tasks;
|
||||||
|
List<api.BridgeTask> get pendingTasks => _tasks.where((t) => t.status != 'completed').toList();
|
||||||
|
List<api.BridgeTask> get completedTasks => _tasks.where((t) => t.status == 'completed').toList();
|
||||||
|
bool get hasWorkspace => _currentWorkspace != null && _workspaces.isNotEmpty;
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
try {
|
||||||
|
final config = await api.initApp();
|
||||||
|
_workspaces = config.workspaces;
|
||||||
|
_currentWorkspace = config.currentWorkspace;
|
||||||
|
if (hasWorkspace) {
|
||||||
|
_screen = AppScreen.tasks;
|
||||||
|
await loadLists();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_screen = AppScreen.setup;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void setScreen(AppScreen s) {
|
||||||
|
_screen = s;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleDarkMode() {
|
||||||
|
_darkMode = !_darkMode;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearError() {
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workspace operations ─────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> addWorkspace(String name, String path) async {
|
||||||
|
try {
|
||||||
|
await api.addWorkspace(name: name, path: path);
|
||||||
|
final config = await api.getConfig();
|
||||||
|
_workspaces = config.workspaces;
|
||||||
|
_currentWorkspace = config.currentWorkspace;
|
||||||
|
_screen = AppScreen.tasks;
|
||||||
|
_error = null;
|
||||||
|
await loadLists();
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> switchWorkspace(String name) async {
|
||||||
|
try {
|
||||||
|
await api.setCurrentWorkspace(name: name);
|
||||||
|
final config = await api.getConfig();
|
||||||
|
_workspaces = config.workspaces;
|
||||||
|
_currentWorkspace = config.currentWorkspace;
|
||||||
|
_activeListId = null;
|
||||||
|
await loadLists();
|
||||||
|
_error = null;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeWorkspace(String name) async {
|
||||||
|
try {
|
||||||
|
await api.removeWorkspace(name: name);
|
||||||
|
final config = await api.getConfig();
|
||||||
|
_workspaces = config.workspaces;
|
||||||
|
_currentWorkspace = config.currentWorkspace;
|
||||||
|
if (!hasWorkspace) {
|
||||||
|
_screen = AppScreen.setup;
|
||||||
|
_lists = [];
|
||||||
|
_tasks = [];
|
||||||
|
_activeListId = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> loadLists() async {
|
||||||
|
try {
|
||||||
|
_lists = await api.getLists();
|
||||||
|
if (_lists.isNotEmpty && _activeListId == null) {
|
||||||
|
_activeListId = _lists.first.id;
|
||||||
|
}
|
||||||
|
if (_activeListId != null) await loadTasks();
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> selectList(String id) async {
|
||||||
|
_activeListId = id;
|
||||||
|
await loadTasks();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createList(String name) async {
|
||||||
|
try {
|
||||||
|
final list = await api.createList(name: name);
|
||||||
|
_lists.add(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.removeWhere((l) => l.id == id);
|
||||||
|
if (_activeListId == id) {
|
||||||
|
_activeListId = _lists.isNotEmpty ? _lists.first.id : null;
|
||||||
|
if (_activeListId != null) await loadTasks();
|
||||||
|
else _tasks = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> loadTasks() async {
|
||||||
|
if (_activeListId == null) return;
|
||||||
|
try {
|
||||||
|
_tasks = await api.listTasks(listId: _activeListId!);
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createTask(String title) async {
|
||||||
|
if (_activeListId == null) return;
|
||||||
|
try {
|
||||||
|
final task = await api.createTask(listId: _activeListId!, title: title);
|
||||||
|
_tasks.add(task);
|
||||||
|
_error = null;
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleTask(String taskId) async {
|
||||||
|
if (_activeListId == null) return;
|
||||||
|
try {
|
||||||
|
await api.toggleTask(listId: _activeListId!, taskId: taskId);
|
||||||
|
await loadTasks();
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateTask(String taskId, String title, String description) async {
|
||||||
|
if (_activeListId == null) return;
|
||||||
|
try {
|
||||||
|
await api.updateTask(listId: _activeListId!, taskId: taskId, title: title, description: description);
|
||||||
|
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.removeWhere((t) => t.id == taskId);
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> triggerSync() async {
|
||||||
|
if (_currentWorkspace == null) return;
|
||||||
|
final ws = _workspaces.where((w) => w.name == _currentWorkspace).firstOrNull;
|
||||||
|
if (ws == null || ws.webdavUrl == null) {
|
||||||
|
_error = 'No WebDAV URL configured';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_syncing = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await api.syncWorkspaceBridge(
|
||||||
|
workspacePath: ws.path,
|
||||||
|
webdavUrl: ws.webdavUrl!,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
);
|
||||||
|
if (result.errors.isNotEmpty) {
|
||||||
|
_error = result.errors.join('; ');
|
||||||
|
}
|
||||||
|
await loadLists();
|
||||||
|
} catch (e) {
|
||||||
|
_error = e.toString();
|
||||||
|
} finally {
|
||||||
|
_syncing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
apps/flutter/lib/screens/settings_screen.dart
Normal file
226
apps/flutter/lib/screens/settings_screen.dart
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
final _webdavUrlController = TextEditingController();
|
||||||
|
final _webdavUserController = TextEditingController();
|
||||||
|
final _webdavPassController = TextEditingController();
|
||||||
|
String? _confirmRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_webdavUrlController.dispose();
|
||||||
|
_webdavUserController.dispose();
|
||||||
|
_webdavPassController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = context.watch<AppProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
onPressed: () => app.setScreen(AppScreen.tasks),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
),
|
||||||
|
title: const Text('Settings'),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// ── Workspaces section ──
|
||||||
|
Text(
|
||||||
|
'WORKSPACES',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(128),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
...app.workspaces.map((ws) {
|
||||||
|
final isCurrent = ws.name == app.currentWorkspace;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
ws.name,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: isCurrent ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
ws.path,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(128),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isCurrent)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => app.switchWorkspace(ws.name),
|
||||||
|
child: const Text('Switch'),
|
||||||
|
),
|
||||||
|
if (_confirmRemove == ws.name) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
app.removeWorkspace(ws.name);
|
||||||
|
setState(() => _confirmRemove = null);
|
||||||
|
},
|
||||||
|
child: Text('Confirm', style: TextStyle(color: theme.colorScheme.error)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() => _confirmRemove = null),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() => _confirmRemove = ws.name),
|
||||||
|
child: Text(
|
||||||
|
'Remove',
|
||||||
|
style: TextStyle(color: theme.colorScheme.onSurfaceVariant.withAlpha(102)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (ws.webdavUrl != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'Sync: ${ws.webdavUrl}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(102),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => app.setScreen(AppScreen.setup),
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text('Add workspace'),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ── WebDAV Sync section ──
|
||||||
|
Text(
|
||||||
|
'WEBDAV SYNC',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(128),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Server URL', style: theme.textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextField(
|
||||||
|
controller: _webdavUrlController,
|
||||||
|
decoration: const InputDecoration(hintText: 'https://dav.example.com/tasks/'),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
Text('Username', style: theme.textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextField(controller: _webdavUserController),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
Text('Password', style: theme.textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextField(
|
||||||
|
controller: _webdavPassController,
|
||||||
|
obscureText: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: test connection
|
||||||
|
},
|
||||||
|
child: const Text('Test Connection'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: save webdav config
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (app.currentWorkspace != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: app.syncing ? null : app.triggerSync,
|
||||||
|
child: Text(app.syncing ? 'Syncing...' : 'Sync Now'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ── Appearance section ──
|
||||||
|
Text(
|
||||||
|
'APPEARANCE',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(128),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
Card(
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: const Text('Dark mode'),
|
||||||
|
value: app.darkMode,
|
||||||
|
onChanged: (_) => app.toggleDarkMode(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
apps/flutter/lib/screens/setup_screen.dart
Normal file
113
apps/flutter/lib/screens/setup_screen.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
|
||||||
|
class SetupScreen extends StatefulWidget {
|
||||||
|
const SetupScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SetupScreen> createState() => _SetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SetupScreenState extends State<SetupScreen> {
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _pathController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_pathController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = context.read<AppProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Bevy Tasks',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Create or open a workspace to get started.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Workspace name
|
||||||
|
Text('Workspace name', style: theme.textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(hintText: 'My Tasks'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Folder path
|
||||||
|
Text('Folder', style: theme.textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _pathController,
|
||||||
|
readOnly: true,
|
||||||
|
decoration: const InputDecoration(hintText: 'Select a folder…'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) {
|
||||||
|
_pathController.text = result;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Browse'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Create button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
final path = _pathController.text.trim();
|
||||||
|
if (name.isNotEmpty && path.isNotEmpty) {
|
||||||
|
app.addWorkspace(name, path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Create Workspace'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
apps/flutter/lib/screens/tasks_screen.dart
Normal file
145
apps/flutter/lib/screens/tasks_screen.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
import '../widgets/task_item.dart';
|
||||||
|
import '../widgets/new_task_bar.dart';
|
||||||
|
import '../widgets/list_picker_sheet.dart';
|
||||||
|
|
||||||
|
class TasksScreen extends StatefulWidget {
|
||||||
|
const TasksScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TasksScreen> createState() => _TasksScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TasksScreenState extends State<TasksScreen> {
|
||||||
|
bool _showCompleted = true;
|
||||||
|
|
||||||
|
void _openListPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const ListPickerSheet(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = context.watch<AppProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: GestureDetector(
|
||||||
|
onTap: _openListPicker,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
app.activeList?.title ?? 'Tasks',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (app.syncing)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: app.toggleDarkMode,
|
||||||
|
icon: Icon(app.darkMode ? Icons.light_mode : Icons.dark_mode),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => app.setScreen(AppScreen.settings),
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
if (app.error != null)
|
||||||
|
MaterialBanner(
|
||||||
|
content: Text(app.error!, style: const TextStyle(fontSize: 13)),
|
||||||
|
backgroundColor: theme.colorScheme.errorContainer,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: app.clearError,
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: app.lists.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('No lists yet', style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Tap the title to create one', style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(128),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: app.activeListId == null
|
||||||
|
? Center(
|
||||||
|
child: Text('Select a list', style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
...app.pendingTasks.map((task) => TaskItem(task: task)),
|
||||||
|
if (app.pendingTasks.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No tasks. Add one below.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(102),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (app.completedTasks.isNotEmpty) ...[
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(
|
||||||
|
_showCompleted ? Icons.expand_more : Icons.chevron_right,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Completed (${app.completedTasks.length})',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () => setState(() => _showCompleted = !_showCompleted),
|
||||||
|
),
|
||||||
|
if (_showCompleted)
|
||||||
|
...app.completedTasks.map((task) => TaskItem(task: task)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const NewTaskBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
apps/flutter/lib/src/rust/api.dart
Normal file
260
apps/flutter/lib/src/rust/api.dart
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
// 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_bridge`, `ensure_repo`, `list_to_bridge`, `task_to_bridge`, `with_state`
|
||||||
|
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `AppState`
|
||||||
|
|
||||||
|
/// Initialize the bridge. Must be called once at app startup.
|
||||||
|
Future<BridgeConfig> initApp() => RustLib.instance.api.crateApiInitApp();
|
||||||
|
|
||||||
|
Future<BridgeConfig> getConfig() => RustLib.instance.api.crateApiGetConfig();
|
||||||
|
|
||||||
|
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<BridgeTaskList>> getLists() =>
|
||||||
|
RustLib.instance.api.crateApiGetLists();
|
||||||
|
|
||||||
|
Future<BridgeTaskList> createList({required String name}) =>
|
||||||
|
RustLib.instance.api.crateApiCreateList(name: name);
|
||||||
|
|
||||||
|
Future<void> deleteList({required String listId}) =>
|
||||||
|
RustLib.instance.api.crateApiDeleteList(listId: listId);
|
||||||
|
|
||||||
|
Future<List<BridgeTask>> listTasks({required String listId}) =>
|
||||||
|
RustLib.instance.api.crateApiListTasks(listId: listId);
|
||||||
|
|
||||||
|
Future<BridgeTask> createTask(
|
||||||
|
{required String listId, required String title}) =>
|
||||||
|
RustLib.instance.api.crateApiCreateTask(listId: listId, title: title);
|
||||||
|
|
||||||
|
Future<BridgeTask> toggleTask(
|
||||||
|
{required String listId, required String taskId}) =>
|
||||||
|
RustLib.instance.api.crateApiToggleTask(listId: listId, taskId: taskId);
|
||||||
|
|
||||||
|
Future<void> updateTask(
|
||||||
|
{required String listId,
|
||||||
|
required String taskId,
|
||||||
|
required String title,
|
||||||
|
required String description}) =>
|
||||||
|
RustLib.instance.api.crateApiUpdateTask(
|
||||||
|
listId: listId, taskId: taskId, title: title, description: description);
|
||||||
|
|
||||||
|
Future<void> deleteTask({required String listId, required String taskId}) =>
|
||||||
|
RustLib.instance.api.crateApiDeleteTask(listId: listId, taskId: taskId);
|
||||||
|
|
||||||
|
Future<void> reorderTask(
|
||||||
|
{required String listId,
|
||||||
|
required String taskId,
|
||||||
|
required BigInt newPosition}) =>
|
||||||
|
RustLib.instance.api.crateApiReorderTask(
|
||||||
|
listId: listId, taskId: taskId, newPosition: newPosition);
|
||||||
|
|
||||||
|
Future<void> setWebdavConfig(
|
||||||
|
{required String workspaceName, required String webdavUrl}) =>
|
||||||
|
RustLib.instance.api.crateApiSetWebdavConfig(
|
||||||
|
workspaceName: workspaceName, webdavUrl: webdavUrl);
|
||||||
|
|
||||||
|
Future<void> storeWebdavCredentials(
|
||||||
|
{required String domain,
|
||||||
|
required String username,
|
||||||
|
required String password}) =>
|
||||||
|
RustLib.instance.api.crateApiStoreWebdavCredentials(
|
||||||
|
domain: domain, username: username, password: password);
|
||||||
|
|
||||||
|
Future<BridgeSyncResult> syncWorkspaceBridge(
|
||||||
|
{required String workspacePath,
|
||||||
|
required String webdavUrl,
|
||||||
|
required String username,
|
||||||
|
required String password}) =>
|
||||||
|
RustLib.instance.api.crateApiSyncWorkspaceBridge(
|
||||||
|
workspacePath: workspacePath,
|
||||||
|
webdavUrl: webdavUrl,
|
||||||
|
username: username,
|
||||||
|
password: password);
|
||||||
|
|
||||||
|
/// Flat app config for FFI transport.
|
||||||
|
class BridgeConfig {
|
||||||
|
final List<BridgeWorkspace> workspaces;
|
||||||
|
final String? currentWorkspace;
|
||||||
|
|
||||||
|
const BridgeConfig({
|
||||||
|
required this.workspaces,
|
||||||
|
this.currentWorkspace,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => workspaces.hashCode ^ currentWorkspace.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BridgeConfig &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
workspaces == other.workspaces &&
|
||||||
|
currentWorkspace == other.currentWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync result for FFI transport.
|
||||||
|
class BridgeSyncResult {
|
||||||
|
final int uploaded;
|
||||||
|
final int downloaded;
|
||||||
|
final int deletedLocal;
|
||||||
|
final int deletedRemote;
|
||||||
|
final int conflicts;
|
||||||
|
final List<String> errors;
|
||||||
|
|
||||||
|
const BridgeSyncResult({
|
||||||
|
required this.uploaded,
|
||||||
|
required this.downloaded,
|
||||||
|
required this.deletedLocal,
|
||||||
|
required this.deletedRemote,
|
||||||
|
required this.conflicts,
|
||||||
|
required this.errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
uploaded.hashCode ^
|
||||||
|
downloaded.hashCode ^
|
||||||
|
deletedLocal.hashCode ^
|
||||||
|
deletedRemote.hashCode ^
|
||||||
|
conflicts.hashCode ^
|
||||||
|
errors.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BridgeSyncResult &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
uploaded == other.uploaded &&
|
||||||
|
downloaded == other.downloaded &&
|
||||||
|
deletedLocal == other.deletedLocal &&
|
||||||
|
deletedRemote == other.deletedRemote &&
|
||||||
|
conflicts == other.conflicts &&
|
||||||
|
errors == other.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat task struct for FFI transport.
|
||||||
|
class BridgeTask {
|
||||||
|
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 BridgeTask({
|
||||||
|
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 BridgeTask &&
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat list struct for FFI transport.
|
||||||
|
class BridgeTaskList {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String createdAt;
|
||||||
|
final String updatedAt;
|
||||||
|
final bool groupByDueDate;
|
||||||
|
|
||||||
|
const BridgeTaskList({
|
||||||
|
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 BridgeTaskList &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
id == other.id &&
|
||||||
|
title == other.title &&
|
||||||
|
createdAt == other.createdAt &&
|
||||||
|
updatedAt == other.updatedAt &&
|
||||||
|
groupByDueDate == other.groupByDueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat workspace config for FFI transport.
|
||||||
|
class BridgeWorkspace {
|
||||||
|
final String name;
|
||||||
|
final String path;
|
||||||
|
final String? webdavUrl;
|
||||||
|
final String? lastSync;
|
||||||
|
|
||||||
|
const BridgeWorkspace({
|
||||||
|
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 BridgeWorkspace &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
name == other.name &&
|
||||||
|
path == other.path &&
|
||||||
|
webdavUrl == other.webdavUrl &&
|
||||||
|
lastSync == other.lastSync;
|
||||||
|
}
|
||||||
1072
apps/flutter/lib/src/rust/frb_generated.dart
Normal file
1072
apps/flutter/lib/src/rust/frb_generated.dart
Normal file
File diff suppressed because it is too large
Load diff
203
apps/flutter/lib/src/rust/frb_generated.io.dart
Normal file
203
apps/flutter/lib/src/rust/frb_generated.io.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
// 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
|
||||||
|
bool dco_decode_bool(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeConfig dco_decode_bridge_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeSyncResult dco_decode_bridge_sync_result(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeTask dco_decode_bridge_task(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeTaskList dco_decode_bridge_task_list(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeWorkspace dco_decode_bridge_workspace(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<String> dco_decode_list_String(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<BridgeTask> dco_decode_list_bridge_task(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<BridgeTaskList> dco_decode_list_bridge_task_list(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<BridgeWorkspace> dco_decode_list_bridge_workspace(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
String? dco_decode_opt_String(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
|
||||||
|
BigInt dco_decode_usize(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
String sse_decode_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
bool sse_decode_bool(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeConfig sse_decode_bridge_config(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeSyncResult sse_decode_bridge_sync_result(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeTask sse_decode_bridge_task(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeTaskList sse_decode_bridge_task_list(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeWorkspace sse_decode_bridge_workspace(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<BridgeTask> sse_decode_list_bridge_task(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<BridgeTaskList> sse_decode_list_bridge_task_list(
|
||||||
|
SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<BridgeWorkspace> sse_decode_list_bridge_workspace(
|
||||||
|
SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
String? sse_decode_opt_String(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
|
||||||
|
BigInt sse_decode_usize(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int sse_decode_i_32(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_String(String self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_config(BridgeConfig self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_sync_result(
|
||||||
|
BridgeSyncResult self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_task(BridgeTask self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_task_list(
|
||||||
|
BridgeTaskList self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_workspace(
|
||||||
|
BridgeWorkspace self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_bridge_task(
|
||||||
|
List<BridgeTask> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_bridge_task_list(
|
||||||
|
List<BridgeTaskList> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_bridge_workspace(
|
||||||
|
List<BridgeWorkspace> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_prim_u_8_strict(
|
||||||
|
Uint8List self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_String(String? 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_usize(BigInt 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;
|
||||||
|
}
|
||||||
150
apps/flutter/lib/src/rust/frb_generated.web.dart
Normal file
150
apps/flutter/lib/src/rust/frb_generated.web.dart
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
// 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 bool dco_decode_bool(dynamic raw);
|
||||||
|
|
||||||
|
@protected BridgeConfig dco_decode_bridge_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected BridgeSyncResult dco_decode_bridge_sync_result(dynamic raw);
|
||||||
|
|
||||||
|
@protected BridgeTask dco_decode_bridge_task(dynamic raw);
|
||||||
|
|
||||||
|
@protected BridgeTaskList dco_decode_bridge_task_list(dynamic raw);
|
||||||
|
|
||||||
|
@protected BridgeWorkspace dco_decode_bridge_workspace(dynamic raw);
|
||||||
|
|
||||||
|
@protected List<String> dco_decode_list_String(dynamic raw);
|
||||||
|
|
||||||
|
@protected List<BridgeTask> dco_decode_list_bridge_task(dynamic raw);
|
||||||
|
|
||||||
|
@protected List<BridgeTaskList> dco_decode_list_bridge_task_list(dynamic raw);
|
||||||
|
|
||||||
|
@protected List<BridgeWorkspace> dco_decode_list_bridge_workspace(dynamic raw);
|
||||||
|
|
||||||
|
@protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
|
@protected String? dco_decode_opt_String(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 BigInt dco_decode_usize(dynamic raw);
|
||||||
|
|
||||||
|
@protected String sse_decode_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected bool sse_decode_bool(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected BridgeConfig sse_decode_bridge_config(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected BridgeSyncResult sse_decode_bridge_sync_result(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected BridgeTask sse_decode_bridge_task(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected BridgeTaskList sse_decode_bridge_task_list(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected BridgeWorkspace sse_decode_bridge_workspace(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected List<BridgeTask> sse_decode_list_bridge_task(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected List<BridgeTaskList> sse_decode_list_bridge_task_list(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected List<BridgeWorkspace> sse_decode_list_bridge_workspace(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected String? sse_decode_opt_String(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 BigInt sse_decode_usize(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected int sse_decode_i_32(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_String(String self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_bool(bool self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_bridge_config(BridgeConfig self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_bridge_sync_result(BridgeSyncResult self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_bridge_task(BridgeTask self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_bridge_task_list(BridgeTaskList self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_bridge_workspace(BridgeWorkspace self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_list_bridge_task(List<BridgeTask> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_list_bridge_task_list(List<BridgeTaskList> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_list_bridge_workspace(List<BridgeWorkspace> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_list_prim_u_8_strict(Uint8List self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected void sse_encode_opt_String(String? 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_usize(BigInt 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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
116
apps/flutter/lib/theme.dart
Normal file
116
apps/flutter/lib/theme.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
class AppTheme {
|
||||||
|
static const _blue = Color(0xFF2563EB);
|
||||||
|
|
||||||
|
static ThemeData light() {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
colorSchemeSeed: _blue,
|
||||||
|
scaffoldBackgroundColor: Colors.white,
|
||||||
|
textTheme: GoogleFonts.notoSansTextTheme(),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Color(0xFF1F2937),
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
),
|
||||||
|
cardTheme: const CardThemeData(
|
||||||
|
color: Color(0xFFF9FAFB),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: _blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 4,
|
||||||
|
shape: CircleBorder(),
|
||||||
|
),
|
||||||
|
bottomSheetTheme: const BottomSheetThemeData(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: Color(0xFFE5E7EB),
|
||||||
|
thickness: 1,
|
||||||
|
space: 0,
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: _blue, width: 2),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ThemeData dark() {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
colorSchemeSeed: _blue,
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||||
|
textTheme: GoogleFonts.notoSansTextTheme(ThemeData.dark().textTheme),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF121212),
|
||||||
|
foregroundColor: Color(0xFFE5E7EB),
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
),
|
||||||
|
cardTheme: const CardThemeData(
|
||||||
|
color: Color(0xFF1E1E1E),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: _blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 4,
|
||||||
|
shape: CircleBorder(),
|
||||||
|
),
|
||||||
|
bottomSheetTheme: const BottomSheetThemeData(
|
||||||
|
backgroundColor: Color(0xFF1E1E1E),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: Color(0xFF374151),
|
||||||
|
thickness: 1,
|
||||||
|
space: 0,
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFF374151)),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFF374151)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: _blue, width: 2),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
apps/flutter/lib/widgets/list_picker_sheet.dart
Normal file
128
apps/flutter/lib/widgets/list_picker_sheet.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
|
||||||
|
class ListPickerSheet extends StatefulWidget {
|
||||||
|
const ListPickerSheet({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ListPickerSheet> createState() => _ListPickerSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ListPickerSheetState extends State<ListPickerSheet> {
|
||||||
|
bool _showNewList = false;
|
||||||
|
final _newListController = TextEditingController();
|
||||||
|
String? _confirmDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_newListController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = context.watch<AppProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
color: theme.dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Text('Lists', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
...app.lists.map((list) {
|
||||||
|
final isActive = list.id == app.activeListId;
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
list.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isActive ? FontWeight.bold : null,
|
||||||
|
color: isActive ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: _confirmDelete == list.id
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
app.deleteList(list.id);
|
||||||
|
setState(() => _confirmDelete = null);
|
||||||
|
},
|
||||||
|
child: Text('Delete', style: TextStyle(color: theme.colorScheme.error)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() => _confirmDelete = null),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
onPressed: () => setState(() => _confirmDelete = list.id),
|
||||||
|
icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.onSurfaceVariant.withAlpha(77)),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
app.selectList(list.id);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
if (_showNewList)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _newListController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(hintText: 'List name'),
|
||||||
|
onSubmitted: (_) => _createList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _createList,
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => setState(() => _showNewList = true),
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text('New list'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _createList() {
|
||||||
|
final name = _newListController.text.trim();
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
context.read<AppProvider>().createList(name);
|
||||||
|
_newListController.clear();
|
||||||
|
setState(() => _showNewList = false);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
apps/flutter/lib/widgets/new_task_bar.dart
Normal file
59
apps/flutter/lib/widgets/new_task_bar.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
|
||||||
|
class NewTaskBar extends StatefulWidget {
|
||||||
|
const NewTaskBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NewTaskBar> createState() => _NewTaskBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewTaskBarState extends State<NewTaskBar> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final title = _controller.text.trim();
|
||||||
|
if (title.isEmpty) return;
|
||||||
|
context.read<AppProvider>().createTask(title);
|
||||||
|
_controller.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = context.watch<AppProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: theme.dividerTheme.color ?? theme.dividerColor)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
enabled: app.activeListId != null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: app.activeListId != null ? 'Add a task...' : 'Select a list first',
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FloatingActionButton.small(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
apps/flutter/lib/widgets/task_item.dart
Normal file
212
apps/flutter/lib/widgets/task_item.dart
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../src/rust/api.dart' as api;
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
|
||||||
|
class TaskItem extends StatelessWidget {
|
||||||
|
final api.BridgeTask task;
|
||||||
|
|
||||||
|
const TaskItem({super.key, required this.task});
|
||||||
|
|
||||||
|
bool get _isCompleted => task.status == 'completed';
|
||||||
|
|
||||||
|
String _formatDate(String iso) {
|
||||||
|
final d = DateTime.tryParse(iso);
|
||||||
|
if (d == null) return iso;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final taskDate = DateTime(d.year, d.month, d.day);
|
||||||
|
|
||||||
|
if (taskDate == today) return 'Today';
|
||||||
|
if (taskDate == today.add(const Duration(days: 1))) return 'Tomorrow';
|
||||||
|
const months = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return '${months[d.month]} ${d.day}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final app = context.read<AppProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(task.id),
|
||||||
|
direction: _isCompleted ? DismissDirection.startToEnd : DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
alignment: _isCompleted ? Alignment.centerLeft : Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Text(
|
||||||
|
_isCompleted ? 'Undo' : 'Complete',
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
await app.toggleTask(task.id);
|
||||||
|
return false; // Don't remove widget — loadTasks handles the rebuild
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _showEditSheet(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Checkbox
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => app.toggleTask(task.id),
|
||||||
|
child: Container(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
margin: const EdgeInsets.only(top: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: _isCompleted
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant.withAlpha(102),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
color: _isCompleted ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
child: _isCompleted
|
||||||
|
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: _isCompleted ? null : FontWeight.w500,
|
||||||
|
decoration: _isCompleted ? TextDecoration.lineThrough : null,
|
||||||
|
color: _isCompleted
|
||||||
|
? theme.colorScheme.onSurfaceVariant.withAlpha(128)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.description.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
task.description,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(102),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.dueDate != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.dividerTheme.color ?? theme.dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_formatDate(task.dueDate!),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(128),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => app.deleteTask(task.id),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(60),
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditSheet(BuildContext context) {
|
||||||
|
final app = context.read<AppProvider>();
|
||||||
|
final titleController = TextEditingController(text: task.title);
|
||||||
|
final descController = TextEditingController(text: task.description);
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (ctx) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
color: Theme.of(ctx).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
enabled: !_isCompleted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
|
maxLines: 3,
|
||||||
|
enabled: !_isCompleted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _isCompleted
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
app.updateTask(task.id, titleController.text.trim(), descController.text);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
490
apps/flutter/pubspec.lock
Normal file
490
apps/flutter/pubspec.lock
Normal file
|
|
@ -0,0 +1,490 @@
|
||||||
|
# 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: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.0"
|
||||||
|
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: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.33"
|
||||||
|
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"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.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: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.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: "direct main"
|
||||||
|
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: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.22"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
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.10.3 <4.0.0"
|
||||||
|
flutter: ">=3.38.4"
|
||||||
26
apps/flutter/pubspec.yaml
Normal file
26
apps/flutter/pubspec.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: bevy_tasks
|
||||||
|
description: A cross-platform task management app built with Flutter and Rust.
|
||||||
|
publish_to: "none"
|
||||||
|
version: 0.1.0
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ">=3.2.0 <4.0.0"
|
||||||
|
flutter: ">=3.16.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_rust_bridge: ^2.0.0
|
||||||
|
google_fonts: ^6.1.0
|
||||||
|
provider: ^6.1.0
|
||||||
|
path_provider: ^2.1.0
|
||||||
|
file_picker: ^8.0.0
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
16
apps/flutter/rust/Cargo.toml
Normal file
16
apps/flutter/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "bevy-tasks-flutter-bridge"
|
||||||
|
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" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["serde", "v4"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
323
apps/flutter/rust/src/api.rs
Normal file
323
apps/flutter/rust/src/api.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use bevy_tasks_core::{
|
||||||
|
config::{AppConfig, WorkspaceConfig},
|
||||||
|
models::{Task, TaskList, TaskStatus},
|
||||||
|
repository::TaskRepository,
|
||||||
|
sync::{self, SyncMode, SyncResult as CoreSyncResult},
|
||||||
|
webdav,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Bridge types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Flat task struct for FFI transport.
|
||||||
|
pub struct BridgeTask {
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat list struct for FFI transport.
|
||||||
|
pub struct BridgeTaskList {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub group_by_due_date: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat workspace config for FFI transport.
|
||||||
|
pub struct BridgeWorkspace {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub webdav_url: Option<String>,
|
||||||
|
pub last_sync: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat app config for FFI transport.
|
||||||
|
pub struct BridgeConfig {
|
||||||
|
pub workspaces: Vec<BridgeWorkspace>,
|
||||||
|
pub current_workspace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync result for FFI transport.
|
||||||
|
pub struct BridgeSyncResult {
|
||||||
|
pub uploaded: u32,
|
||||||
|
pub downloaded: u32,
|
||||||
|
pub deleted_local: u32,
|
||||||
|
pub deleted_remote: u32,
|
||||||
|
pub conflicts: u32,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversion helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn task_to_bridge(t: &Task) -> BridgeTask {
|
||||||
|
BridgeTask {
|
||||||
|
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 list_to_bridge(l: &TaskList) -> BridgeTaskList {
|
||||||
|
BridgeTaskList {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_to_bridge(c: &AppConfig) -> BridgeConfig {
|
||||||
|
BridgeConfig {
|
||||||
|
workspaces: c
|
||||||
|
.workspaces
|
||||||
|
.iter()
|
||||||
|
.map(|(name, ws)| BridgeWorkspace {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static STATE: Mutex<Option<AppState>> = Mutex::new(None);
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
config: AppConfig,
|
||||||
|
repo: Option<TaskRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_state<T>(f: impl FnOnce(&mut AppState) -> Result<T, String>) -> Result<T, String> {
|
||||||
|
let mut guard = STATE.lock().map_err(|e| e.to_string())?;
|
||||||
|
let state = guard.as_mut().ok_or("App not initialized")?;
|
||||||
|
f(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API (flutter_rust_bridge will generate Dart bindings) ─────
|
||||||
|
|
||||||
|
/// Initialize the bridge. Must be called once at app startup.
|
||||||
|
pub fn init_app() -> Result<BridgeConfig, String> {
|
||||||
|
let config_path = AppConfig::get_config_path();
|
||||||
|
let config = AppConfig::load_from_file(&config_path).unwrap_or_default();
|
||||||
|
let bridge_config = config_to_bridge(&config);
|
||||||
|
let mut guard = STATE.lock().map_err(|e| e.to_string())?;
|
||||||
|
*guard = Some(AppState { config, repo: None });
|
||||||
|
Ok(bridge_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_config() -> Result<BridgeConfig, String> {
|
||||||
|
with_state(|s| Ok(config_to_bridge(&s.config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_workspace(name: String, path: String) -> Result<(), String> {
|
||||||
|
// Init workspace on disk
|
||||||
|
TaskRepository::init(PathBuf::from(&path))
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
with_state(|s| {
|
||||||
|
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> {
|
||||||
|
with_state(|s| {
|
||||||
|
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> {
|
||||||
|
with_state(|s| {
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_lists() -> Result<Vec<BridgeTaskList>, String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
s.repo
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.get_lists()
|
||||||
|
.map(|lists| lists.iter().map(|l| list_to_bridge(l)).collect())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_list(name: String) -> Result<BridgeTaskList, String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
s.repo
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.create_list(name)
|
||||||
|
.map(|l| list_to_bridge(&l))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_list(list_id: String) -> Result<(), String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
let id = uuid::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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_tasks(list_id: String) -> Result<Vec<BridgeTask>, String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
let id = uuid::Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
|
||||||
|
s.repo
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.list_tasks(id)
|
||||||
|
.map(|tasks| tasks.iter().map(|t| task_to_bridge(t)).collect())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_task(list_id: String, title: String) -> Result<BridgeTask, String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
let id = uuid::Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
|
||||||
|
let task = Task::new(title);
|
||||||
|
s.repo
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.create_task(id, task)
|
||||||
|
.map(|t| task_to_bridge(&t))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_task(list_id: String, task_id: String) -> Result<BridgeTask, String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
let lid = uuid::Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
|
||||||
|
let tid = uuid::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_bridge(&task))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_task(list_id: String, task_id: String, title: String, description: String) -> Result<(), String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
let lid = uuid::Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
|
||||||
|
let tid = uuid::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())?;
|
||||||
|
task.title = title;
|
||||||
|
task.description = description;
|
||||||
|
repo.update_task(lid, task).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_task(list_id: String, task_id: String) -> Result<(), String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
let lid = uuid::Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
|
||||||
|
let tid = uuid::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 reorder_task(list_id: String, task_id: String, new_position: usize) -> Result<(), String> {
|
||||||
|
with_state(|s| {
|
||||||
|
ensure_repo(s)?;
|
||||||
|
let lid = uuid::Uuid::parse_str(&list_id).map_err(|e| e.to_string())?;
|
||||||
|
let tid = uuid::Uuid::parse_str(&task_id).map_err(|e| e.to_string())?;
|
||||||
|
s.repo.as_mut().unwrap().reorder_task(lid, tid, new_position).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_webdav_config(workspace_name: String, webdav_url: String) -> Result<(), String> {
|
||||||
|
with_state(|s| {
|
||||||
|
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
|
||||||
|
ws.webdav_url = Some(webdav_url);
|
||||||
|
}
|
||||||
|
let config_path = AppConfig::get_config_path();
|
||||||
|
s.config.save_to_file(&config_path).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_webdav_credentials(domain: String, username: String, password: String) -> Result<(), String> {
|
||||||
|
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_workspace_bridge(
|
||||||
|
workspace_path: String,
|
||||||
|
webdav_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<BridgeSyncResult, String> {
|
||||||
|
let result = sync::sync_workspace(
|
||||||
|
&PathBuf::from(workspace_path),
|
||||||
|
&webdav_url,
|
||||||
|
&username,
|
||||||
|
&password,
|
||||||
|
SyncMode::Full,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(BridgeSyncResult {
|
||||||
|
uploaded: result.uploaded,
|
||||||
|
downloaded: result.downloaded,
|
||||||
|
deleted_local: result.deleted_local,
|
||||||
|
deleted_remote: result.deleted_remote,
|
||||||
|
conflicts: result.conflicts,
|
||||||
|
errors: result.errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
1205
apps/flutter/rust/src/frb_generated.rs
Normal file
1205
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 @@
|
||||||
|
pub mod api;
|
||||||
|
mod frb_generated;
|
||||||
30
apps/flutter/test/widget_test.dart
Normal file
30
apps/flutter/test/widget_test.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// This is a basic Flutter widget test.
|
||||||
|
//
|
||||||
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
|
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||||
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:bevy_tasks/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
|
// Build our app and trigger a frame.
|
||||||
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
|
// Verify that our counter starts at 0.
|
||||||
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
expect(find.text('1'), findsNothing);
|
||||||
|
|
||||||
|
// Tap the '+' icon and trigger a frame.
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify that our counter has incremented.
|
||||||
|
expect(find.text('0'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
17
apps/flutter/windows/.gitignore
vendored
Normal file
17
apps/flutter/windows/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
flutter/ephemeral/
|
||||||
|
|
||||||
|
# Visual Studio user-specific files.
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# Visual Studio build-related files.
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!*.[Cc]ache/
|
||||||
122
apps/flutter/windows/CMakeLists.txt
Normal file
122
apps/flutter/windows/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Project-level configuration.
|
||||||
|
cmake_minimum_required(VERSION 3.14)
|
||||||
|
project(bevy_tasks 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")
|
||||||
|
|
||||||
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
|
# versions of CMake.
|
||||||
|
cmake_policy(VERSION 3.14...3.25)
|
||||||
|
|
||||||
|
# Define build configuration option.
|
||||||
|
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
|
||||||
|
if(IS_MULTICONFIG)
|
||||||
|
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
|
||||||
|
CACHE STRING "" FORCE)
|
||||||
|
else()
|
||||||
|
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||||
|
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||||
|
STRING "Flutter build mode" FORCE)
|
||||||
|
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||||
|
"Debug" "Profile" "Release")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
# Define settings for the Profile build mode.
|
||||||
|
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
|
||||||
|
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
|
||||||
|
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
|
||||||
|
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
|
||||||
|
|
||||||
|
# Use Unicode for all projects.
|
||||||
|
add_definitions(-DUNICODE -D_UNICODE)
|
||||||
|
|
||||||
|
# Compilation settings that should be applied to most targets.
|
||||||
|
#
|
||||||
|
# Be cautious about adding new options here, as plugins use this function by
|
||||||
|
# default. In most cases, you should add new options to specific targets instead
|
||||||
|
# of modifying this function.
|
||||||
|
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||||
|
target_compile_features(${TARGET} PUBLIC cxx_std_17)
|
||||||
|
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
|
||||||
|
target_compile_options(${TARGET} PRIVATE /EHsc)
|
||||||
|
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
|
||||||
|
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
# Flutter library and tool build rules.
|
||||||
|
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||||
|
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||||
|
|
||||||
|
# Application build; see runner/CMakeLists.txt.
|
||||||
|
add_subdirectory("runner")
|
||||||
|
|
||||||
|
|
||||||
|
# Generated plugin build rules, which manage building the plugins and adding
|
||||||
|
# them to the application.
|
||||||
|
include(flutter/generated_plugins.cmake)
|
||||||
|
|
||||||
|
|
||||||
|
# === Rust bridge library ===
|
||||||
|
# Build the Rust bridge crate and install the DLL alongside the app.
|
||||||
|
set(RUST_BRIDGE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../rust")
|
||||||
|
set(RUST_LIB_NAME "bevy_tasks_flutter_bridge")
|
||||||
|
|
||||||
|
if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "Profile")
|
||||||
|
set(RUST_TARGET_DIR "${RUST_BRIDGE_DIR}/target/release")
|
||||||
|
else()
|
||||||
|
set(RUST_TARGET_DIR "${RUST_BRIDGE_DIR}/target/debug")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Add the Rust DLL to the bundled libraries list so it gets installed
|
||||||
|
list(APPEND PLUGIN_BUNDLED_LIBRARIES "${RUST_TARGET_DIR}/${RUST_LIB_NAME}.dll")
|
||||||
|
|
||||||
|
# === Installation ===
|
||||||
|
# Support files are copied into place next to the executable, so that it can
|
||||||
|
# run in place. This is done instead of making a separate bundle (as on Linux)
|
||||||
|
# so that building and running from within Visual Studio will work.
|
||||||
|
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
|
||||||
|
# Make the "install" step default, as it's required to run.
|
||||||
|
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
|
||||||
|
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||||
|
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||||
|
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
|
||||||
|
|
||||||
|
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
if(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
|
||||||
|
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Copy the native assets provided by the build.dart from all packages.
|
||||||
|
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
|
||||||
|
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||||
|
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||||
|
# from a previous install.
|
||||||
|
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||||
|
install(CODE "
|
||||||
|
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||||
|
" COMPONENT Runtime)
|
||||||
|
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||||
|
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||||
|
|
||||||
|
# Install the AOT library on non-Debug builds only.
|
||||||
|
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||||
|
CONFIGURATIONS Profile;Release
|
||||||
|
COMPONENT Runtime)
|
||||||
109
apps/flutter/windows/flutter/CMakeLists.txt
Normal file
109
apps/flutter/windows/flutter/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# This file controls Flutter-level build steps. It should not be edited.
|
||||||
|
cmake_minimum_required(VERSION 3.14)
|
||||||
|
|
||||||
|
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||||
|
|
||||||
|
# Configuration provided via flutter tool.
|
||||||
|
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||||
|
|
||||||
|
# TODO: Move the rest of this into files in ephemeral. See
|
||||||
|
# https://github.com/flutter/flutter/issues/57146.
|
||||||
|
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
|
||||||
|
|
||||||
|
# Set fallback configurations for older versions of the flutter tool.
|
||||||
|
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
|
||||||
|
set(FLUTTER_TARGET_PLATFORM "windows-x64")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# === Flutter Library ===
|
||||||
|
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
|
||||||
|
|
||||||
|
# Published to parent scope for install step.
|
||||||
|
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||||
|
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||||
|
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||||
|
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
|
||||||
|
|
||||||
|
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||||
|
"flutter_export.h"
|
||||||
|
"flutter_windows.h"
|
||||||
|
"flutter_messenger.h"
|
||||||
|
"flutter_plugin_registrar.h"
|
||||||
|
"flutter_texture_registrar.h"
|
||||||
|
)
|
||||||
|
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
|
||||||
|
add_library(flutter INTERFACE)
|
||||||
|
target_include_directories(flutter INTERFACE
|
||||||
|
"${EPHEMERAL_DIR}"
|
||||||
|
)
|
||||||
|
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
|
||||||
|
add_dependencies(flutter flutter_assemble)
|
||||||
|
|
||||||
|
# === Wrapper ===
|
||||||
|
list(APPEND CPP_WRAPPER_SOURCES_CORE
|
||||||
|
"core_implementations.cc"
|
||||||
|
"standard_codec.cc"
|
||||||
|
)
|
||||||
|
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
|
||||||
|
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
|
||||||
|
"plugin_registrar.cc"
|
||||||
|
)
|
||||||
|
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
|
||||||
|
list(APPEND CPP_WRAPPER_SOURCES_APP
|
||||||
|
"flutter_engine.cc"
|
||||||
|
"flutter_view_controller.cc"
|
||||||
|
)
|
||||||
|
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
|
||||||
|
|
||||||
|
# Wrapper sources needed for a plugin.
|
||||||
|
add_library(flutter_wrapper_plugin STATIC
|
||||||
|
${CPP_WRAPPER_SOURCES_CORE}
|
||||||
|
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||||
|
)
|
||||||
|
apply_standard_settings(flutter_wrapper_plugin)
|
||||||
|
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||||
|
POSITION_INDEPENDENT_CODE ON)
|
||||||
|
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||||
|
CXX_VISIBILITY_PRESET hidden)
|
||||||
|
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
|
||||||
|
target_include_directories(flutter_wrapper_plugin PUBLIC
|
||||||
|
"${WRAPPER_ROOT}/include"
|
||||||
|
)
|
||||||
|
add_dependencies(flutter_wrapper_plugin flutter_assemble)
|
||||||
|
|
||||||
|
# Wrapper sources needed for the runner.
|
||||||
|
add_library(flutter_wrapper_app STATIC
|
||||||
|
${CPP_WRAPPER_SOURCES_CORE}
|
||||||
|
${CPP_WRAPPER_SOURCES_APP}
|
||||||
|
)
|
||||||
|
apply_standard_settings(flutter_wrapper_app)
|
||||||
|
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
|
||||||
|
target_include_directories(flutter_wrapper_app PUBLIC
|
||||||
|
"${WRAPPER_ROOT}/include"
|
||||||
|
)
|
||||||
|
add_dependencies(flutter_wrapper_app flutter_assemble)
|
||||||
|
|
||||||
|
# === Flutter tool backend ===
|
||||||
|
# _phony_ is a non-existent file to force this command to run every time,
|
||||||
|
# since currently there's no way to get a full input/output list from the
|
||||||
|
# flutter tool.
|
||||||
|
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
|
||||||
|
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||||
|
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||||
|
${CPP_WRAPPER_SOURCES_APP}
|
||||||
|
${PHONY_OUTPUT}
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E env
|
||||||
|
${FLUTTER_TOOL_ENVIRONMENT}
|
||||||
|
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
|
||||||
|
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
add_custom_target(flutter_assemble DEPENDS
|
||||||
|
"${FLUTTER_LIBRARY}"
|
||||||
|
${FLUTTER_LIBRARY_HEADERS}
|
||||||
|
${CPP_WRAPPER_SOURCES_CORE}
|
||||||
|
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||||
|
${CPP_WRAPPER_SOURCES_APP}
|
||||||
|
)
|
||||||
11
apps/flutter/windows/flutter/generated_plugin_registrant.cc
Normal file
11
apps/flutter/windows/flutter/generated_plugin_registrant.cc
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
|
||||||
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
|
||||||
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
}
|
||||||
15
apps/flutter/windows/flutter/generated_plugin_registrant.h
Normal file
15
apps/flutter/windows/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/plugin_registry.h>
|
||||||
|
|
||||||
|
// Registers Flutter plugins.
|
||||||
|
void RegisterPlugins(flutter::PluginRegistry* registry);
|
||||||
|
|
||||||
|
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||||
23
apps/flutter/windows/flutter/generated_plugins.cmake
Normal file
23
apps/flutter/windows/flutter/generated_plugins.cmake
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#
|
||||||
|
# Generated file, do not edit.
|
||||||
|
#
|
||||||
|
|
||||||
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
)
|
||||||
|
|
||||||
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
)
|
||||||
|
|
||||||
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
||||||
|
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||||
|
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows 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}/windows plugins/${ffi_plugin})
|
||||||
|
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||||
|
endforeach(ffi_plugin)
|
||||||
40
apps/flutter/windows/runner/CMakeLists.txt
Normal file
40
apps/flutter/windows/runner/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
cmake_minimum_required(VERSION 3.14)
|
||||||
|
project(runner LANGUAGES CXX)
|
||||||
|
|
||||||
|
# Define the application target. To change its name, change BINARY_NAME in the
|
||||||
|
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||||
|
# work.
|
||||||
|
#
|
||||||
|
# Any new source files that you add to the application should be added here.
|
||||||
|
add_executable(${BINARY_NAME} WIN32
|
||||||
|
"flutter_window.cpp"
|
||||||
|
"main.cpp"
|
||||||
|
"utils.cpp"
|
||||||
|
"win32_window.cpp"
|
||||||
|
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||||
|
"Runner.rc"
|
||||||
|
"runner.exe.manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the standard set of build settings. This can be removed for applications
|
||||||
|
# that need different build settings.
|
||||||
|
apply_standard_settings(${BINARY_NAME})
|
||||||
|
|
||||||
|
# Add preprocessor definitions for the build version.
|
||||||
|
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
|
||||||
|
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
|
||||||
|
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
|
||||||
|
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
|
||||||
|
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
|
||||||
|
|
||||||
|
# Disable Windows macros that collide with C++ standard library functions.
|
||||||
|
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
|
||||||
|
|
||||||
|
# Add dependency libraries and include directories. Add any application-specific
|
||||||
|
# dependencies here.
|
||||||
|
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
||||||
|
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
||||||
|
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||||
|
|
||||||
|
# Run the Flutter tool portions of the build. This must not be removed.
|
||||||
|
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||||
121
apps/flutter/windows/runner/Runner.rc
Normal file
121
apps/flutter/windows/runner/Runner.rc
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Microsoft Visual C++ generated resource script.
|
||||||
|
//
|
||||||
|
#pragma code_page(65001)
|
||||||
|
#include "resource.h"
|
||||||
|
|
||||||
|
#define APSTUDIO_READONLY_SYMBOLS
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Generated from the TEXTINCLUDE 2 resource.
|
||||||
|
//
|
||||||
|
#include "winres.h"
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
#undef APSTUDIO_READONLY_SYMBOLS
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
// English (United States) resources
|
||||||
|
|
||||||
|
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
|
||||||
|
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
|
||||||
|
|
||||||
|
#ifdef APSTUDIO_INVOKED
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// TEXTINCLUDE
|
||||||
|
//
|
||||||
|
|
||||||
|
1 TEXTINCLUDE
|
||||||
|
BEGIN
|
||||||
|
"resource.h\0"
|
||||||
|
END
|
||||||
|
|
||||||
|
2 TEXTINCLUDE
|
||||||
|
BEGIN
|
||||||
|
"#include ""winres.h""\r\n"
|
||||||
|
"\0"
|
||||||
|
END
|
||||||
|
|
||||||
|
3 TEXTINCLUDE
|
||||||
|
BEGIN
|
||||||
|
"\r\n"
|
||||||
|
"\0"
|
||||||
|
END
|
||||||
|
|
||||||
|
#endif // APSTUDIO_INVOKED
|
||||||
|
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Icon
|
||||||
|
//
|
||||||
|
|
||||||
|
// Icon with lowest ID value placed first to ensure application icon
|
||||||
|
// remains consistent on all systems.
|
||||||
|
IDI_APP_ICON ICON "resources\\app_icon.ico"
|
||||||
|
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Version
|
||||||
|
//
|
||||||
|
|
||||||
|
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
|
||||||
|
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
|
||||||
|
#else
|
||||||
|
#define VERSION_AS_NUMBER 1,0,0,0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(FLUTTER_VERSION)
|
||||||
|
#define VERSION_AS_STRING FLUTTER_VERSION
|
||||||
|
#else
|
||||||
|
#define VERSION_AS_STRING "1.0.0"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
VS_VERSION_INFO VERSIONINFO
|
||||||
|
FILEVERSION VERSION_AS_NUMBER
|
||||||
|
PRODUCTVERSION VERSION_AS_NUMBER
|
||||||
|
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||||
|
#ifdef _DEBUG
|
||||||
|
FILEFLAGS VS_FF_DEBUG
|
||||||
|
#else
|
||||||
|
FILEFLAGS 0x0L
|
||||||
|
#endif
|
||||||
|
FILEOS VOS__WINDOWS32
|
||||||
|
FILETYPE VFT_APP
|
||||||
|
FILESUBTYPE 0x0L
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "040904e4"
|
||||||
|
BEGIN
|
||||||
|
VALUE "CompanyName", "com.bevytasks" "\0"
|
||||||
|
VALUE "FileDescription", "bevy_tasks" "\0"
|
||||||
|
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||||
|
VALUE "InternalName", "bevy_tasks" "\0"
|
||||||
|
VALUE "LegalCopyright", "Copyright (C) 2026 com.bevytasks. All rights reserved." "\0"
|
||||||
|
VALUE "OriginalFilename", "bevy_tasks.exe" "\0"
|
||||||
|
VALUE "ProductName", "bevy_tasks" "\0"
|
||||||
|
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x409, 1252
|
||||||
|
END
|
||||||
|
END
|
||||||
|
|
||||||
|
#endif // English (United States) resources
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#ifndef APSTUDIO_INVOKED
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Generated from the TEXTINCLUDE 3 resource.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
#endif // not APSTUDIO_INVOKED
|
||||||
1
apps/flutter/windows/runner/bridge_generated.h
Normal file
1
apps/flutter/windows/runner/bridge_generated.h
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// Nothing when using full_dep=false mode
|
||||||
71
apps/flutter/windows/runner/flutter_window.cpp
Normal file
71
apps/flutter/windows/runner/flutter_window.cpp
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#include "flutter_window.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||||
|
: project_(project) {}
|
||||||
|
|
||||||
|
FlutterWindow::~FlutterWindow() {}
|
||||||
|
|
||||||
|
bool FlutterWindow::OnCreate() {
|
||||||
|
if (!Win32Window::OnCreate()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RECT frame = GetClientArea();
|
||||||
|
|
||||||
|
// The size here must match the window dimensions to avoid unnecessary surface
|
||||||
|
// creation / destruction in the startup path.
|
||||||
|
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
|
||||||
|
frame.right - frame.left, frame.bottom - frame.top, project_);
|
||||||
|
// Ensure that basic setup of the controller was successful.
|
||||||
|
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
RegisterPlugins(flutter_controller_->engine());
|
||||||
|
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||||
|
|
||||||
|
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||||
|
this->Show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flutter can complete the first frame before the "show window" callback is
|
||||||
|
// registered. The following call ensures a frame is pending to ensure the
|
||||||
|
// window is shown. It is a no-op if the first frame hasn't completed yet.
|
||||||
|
flutter_controller_->ForceRedraw();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlutterWindow::OnDestroy() {
|
||||||
|
if (flutter_controller_) {
|
||||||
|
flutter_controller_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Win32Window::OnDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT
|
||||||
|
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept {
|
||||||
|
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||||
|
if (flutter_controller_) {
|
||||||
|
std::optional<LRESULT> result =
|
||||||
|
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
|
||||||
|
lparam);
|
||||||
|
if (result) {
|
||||||
|
return *result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message) {
|
||||||
|
case WM_FONTCHANGE:
|
||||||
|
flutter_controller_->engine()->ReloadSystemFonts();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
|
||||||
|
}
|
||||||
33
apps/flutter/windows/runner/flutter_window.h
Normal file
33
apps/flutter/windows/runner/flutter_window.h
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#ifndef RUNNER_FLUTTER_WINDOW_H_
|
||||||
|
#define RUNNER_FLUTTER_WINDOW_H_
|
||||||
|
|
||||||
|
#include <flutter/dart_project.h>
|
||||||
|
#include <flutter/flutter_view_controller.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "win32_window.h"
|
||||||
|
|
||||||
|
// A window that does nothing but host a Flutter view.
|
||||||
|
class FlutterWindow : public Win32Window {
|
||||||
|
public:
|
||||||
|
// Creates a new FlutterWindow hosting a Flutter view running |project|.
|
||||||
|
explicit FlutterWindow(const flutter::DartProject& project);
|
||||||
|
virtual ~FlutterWindow();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Win32Window:
|
||||||
|
bool OnCreate() override;
|
||||||
|
void OnDestroy() override;
|
||||||
|
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// The project to run.
|
||||||
|
flutter::DartProject project_;
|
||||||
|
|
||||||
|
// The Flutter instance hosted by this window.
|
||||||
|
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||||
43
apps/flutter/windows/runner/main.cpp
Normal file
43
apps/flutter/windows/runner/main.cpp
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#include <flutter/dart_project.h>
|
||||||
|
#include <flutter/flutter_view_controller.h>
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include "flutter_window.h"
|
||||||
|
#include "utils.h"
|
||||||
|
|
||||||
|
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||||
|
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||||
|
// Attach to console when present (e.g., 'flutter run') or create a
|
||||||
|
// new console when running with a debugger.
|
||||||
|
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
||||||
|
CreateAndAttachConsole();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize COM, so that it is available for use in the library and/or
|
||||||
|
// plugins.
|
||||||
|
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
|
|
||||||
|
flutter::DartProject project(L"data");
|
||||||
|
|
||||||
|
std::vector<std::string> command_line_arguments =
|
||||||
|
GetCommandLineArguments();
|
||||||
|
|
||||||
|
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
|
||||||
|
|
||||||
|
FlutterWindow window(project);
|
||||||
|
Win32Window::Point origin(10, 10);
|
||||||
|
Win32Window::Size size(1280, 720);
|
||||||
|
if (!window.Create(L"bevy_tasks", origin, size)) {
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
window.SetQuitOnClose(true);
|
||||||
|
|
||||||
|
::MSG msg;
|
||||||
|
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||||
|
::TranslateMessage(&msg);
|
||||||
|
::DispatchMessage(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::CoUninitialize();
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
16
apps/flutter/windows/runner/resource.h
Normal file
16
apps/flutter/windows/runner/resource.h
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//{{NO_DEPENDENCIES}}
|
||||||
|
// Microsoft Visual C++ generated include file.
|
||||||
|
// Used by Runner.rc
|
||||||
|
//
|
||||||
|
#define IDI_APP_ICON 101
|
||||||
|
|
||||||
|
// Next default values for new objects
|
||||||
|
//
|
||||||
|
#ifdef APSTUDIO_INVOKED
|
||||||
|
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||||
|
#define _APS_NEXT_RESOURCE_VALUE 102
|
||||||
|
#define _APS_NEXT_COMMAND_VALUE 40001
|
||||||
|
#define _APS_NEXT_CONTROL_VALUE 1001
|
||||||
|
#define _APS_NEXT_SYMED_VALUE 101
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
BIN
apps/flutter/windows/runner/resources/app_icon.ico
Normal file
BIN
apps/flutter/windows/runner/resources/app_icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
14
apps/flutter/windows/runner/runner.exe.manifest
Normal file
14
apps/flutter/windows/runner/runner.exe.manifest
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10 and Windows 11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
65
apps/flutter/windows/runner/utils.cpp
Normal file
65
apps/flutter/windows/runner/utils.cpp
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#include "utils.h"
|
||||||
|
|
||||||
|
#include <flutter_windows.h>
|
||||||
|
#include <io.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
void CreateAndAttachConsole() {
|
||||||
|
if (::AllocConsole()) {
|
||||||
|
FILE *unused;
|
||||||
|
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
|
||||||
|
_dup2(_fileno(stdout), 1);
|
||||||
|
}
|
||||||
|
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
|
||||||
|
_dup2(_fileno(stdout), 2);
|
||||||
|
}
|
||||||
|
std::ios::sync_with_stdio();
|
||||||
|
FlutterDesktopResyncOutputStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> GetCommandLineArguments() {
|
||||||
|
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
|
||||||
|
int argc;
|
||||||
|
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
|
||||||
|
if (argv == nullptr) {
|
||||||
|
return std::vector<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> command_line_arguments;
|
||||||
|
|
||||||
|
// Skip the first argument as it's the binary name.
|
||||||
|
for (int i = 1; i < argc; i++) {
|
||||||
|
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
::LocalFree(argv);
|
||||||
|
|
||||||
|
return command_line_arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
|
||||||
|
if (utf16_string == nullptr) {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
unsigned int target_length = ::WideCharToMultiByte(
|
||||||
|
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||||
|
-1, nullptr, 0, nullptr, nullptr)
|
||||||
|
-1; // remove the trailing null character
|
||||||
|
int input_length = (int)wcslen(utf16_string);
|
||||||
|
std::string utf8_string;
|
||||||
|
if (target_length == 0 || target_length > utf8_string.max_size()) {
|
||||||
|
return utf8_string;
|
||||||
|
}
|
||||||
|
utf8_string.resize(target_length);
|
||||||
|
int converted_length = ::WideCharToMultiByte(
|
||||||
|
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||||
|
input_length, utf8_string.data(), target_length, nullptr, nullptr);
|
||||||
|
if (converted_length == 0) {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
return utf8_string;
|
||||||
|
}
|
||||||
19
apps/flutter/windows/runner/utils.h
Normal file
19
apps/flutter/windows/runner/utils.h
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#ifndef RUNNER_UTILS_H_
|
||||||
|
#define RUNNER_UTILS_H_
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Creates a console for the process, and redirects stdout and stderr to
|
||||||
|
// it for both the runner and the Flutter library.
|
||||||
|
void CreateAndAttachConsole();
|
||||||
|
|
||||||
|
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
|
||||||
|
// encoded in UTF-8. Returns an empty std::string on failure.
|
||||||
|
std::string Utf8FromUtf16(const wchar_t* utf16_string);
|
||||||
|
|
||||||
|
// Gets the command line arguments passed in as a std::vector<std::string>,
|
||||||
|
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
|
||||||
|
std::vector<std::string> GetCommandLineArguments();
|
||||||
|
|
||||||
|
#endif // RUNNER_UTILS_H_
|
||||||
288
apps/flutter/windows/runner/win32_window.cpp
Normal file
288
apps/flutter/windows/runner/win32_window.cpp
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
#include "win32_window.h"
|
||||||
|
|
||||||
|
#include <dwmapi.h>
|
||||||
|
#include <flutter_windows.h>
|
||||||
|
|
||||||
|
#include "resource.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Window attribute that enables dark mode window decorations.
|
||||||
|
///
|
||||||
|
/// Redefined in case the developer's machine has a Windows SDK older than
|
||||||
|
/// version 10.0.22000.0.
|
||||||
|
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
|
||||||
|
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||||
|
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||||
|
#endif
|
||||||
|
|
||||||
|
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
|
||||||
|
|
||||||
|
/// Registry key for app theme preference.
|
||||||
|
///
|
||||||
|
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
|
||||||
|
/// value indicates apps should use light mode.
|
||||||
|
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
|
||||||
|
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
|
||||||
|
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
|
||||||
|
|
||||||
|
// The number of Win32Window objects that currently exist.
|
||||||
|
static int g_active_window_count = 0;
|
||||||
|
|
||||||
|
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
|
||||||
|
|
||||||
|
// Scale helper to convert logical scaler values to physical using passed in
|
||||||
|
// scale factor
|
||||||
|
int Scale(int source, double scale_factor) {
|
||||||
|
return static_cast<int>(source * scale_factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
|
||||||
|
// This API is only needed for PerMonitor V1 awareness mode.
|
||||||
|
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
|
||||||
|
HMODULE user32_module = LoadLibraryA("User32.dll");
|
||||||
|
if (!user32_module) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto enable_non_client_dpi_scaling =
|
||||||
|
reinterpret_cast<EnableNonClientDpiScaling*>(
|
||||||
|
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
|
||||||
|
if (enable_non_client_dpi_scaling != nullptr) {
|
||||||
|
enable_non_client_dpi_scaling(hwnd);
|
||||||
|
}
|
||||||
|
FreeLibrary(user32_module);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Manages the Win32Window's window class registration.
|
||||||
|
class WindowClassRegistrar {
|
||||||
|
public:
|
||||||
|
~WindowClassRegistrar() = default;
|
||||||
|
|
||||||
|
// Returns the singleton registrar instance.
|
||||||
|
static WindowClassRegistrar* GetInstance() {
|
||||||
|
if (!instance_) {
|
||||||
|
instance_ = new WindowClassRegistrar();
|
||||||
|
}
|
||||||
|
return instance_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the name of the window class, registering the class if it hasn't
|
||||||
|
// previously been registered.
|
||||||
|
const wchar_t* GetWindowClass();
|
||||||
|
|
||||||
|
// Unregisters the window class. Should only be called if there are no
|
||||||
|
// instances of the window.
|
||||||
|
void UnregisterWindowClass();
|
||||||
|
|
||||||
|
private:
|
||||||
|
WindowClassRegistrar() = default;
|
||||||
|
|
||||||
|
static WindowClassRegistrar* instance_;
|
||||||
|
|
||||||
|
bool class_registered_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
|
||||||
|
|
||||||
|
const wchar_t* WindowClassRegistrar::GetWindowClass() {
|
||||||
|
if (!class_registered_) {
|
||||||
|
WNDCLASS window_class{};
|
||||||
|
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||||
|
window_class.lpszClassName = kWindowClassName;
|
||||||
|
window_class.style = CS_HREDRAW | CS_VREDRAW;
|
||||||
|
window_class.cbClsExtra = 0;
|
||||||
|
window_class.cbWndExtra = 0;
|
||||||
|
window_class.hInstance = GetModuleHandle(nullptr);
|
||||||
|
window_class.hIcon =
|
||||||
|
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
|
||||||
|
window_class.hbrBackground = 0;
|
||||||
|
window_class.lpszMenuName = nullptr;
|
||||||
|
window_class.lpfnWndProc = Win32Window::WndProc;
|
||||||
|
RegisterClass(&window_class);
|
||||||
|
class_registered_ = true;
|
||||||
|
}
|
||||||
|
return kWindowClassName;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowClassRegistrar::UnregisterWindowClass() {
|
||||||
|
UnregisterClass(kWindowClassName, nullptr);
|
||||||
|
class_registered_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Win32Window::Win32Window() {
|
||||||
|
++g_active_window_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
Win32Window::~Win32Window() {
|
||||||
|
--g_active_window_count;
|
||||||
|
Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Win32Window::Create(const std::wstring& title,
|
||||||
|
const Point& origin,
|
||||||
|
const Size& size) {
|
||||||
|
Destroy();
|
||||||
|
|
||||||
|
const wchar_t* window_class =
|
||||||
|
WindowClassRegistrar::GetInstance()->GetWindowClass();
|
||||||
|
|
||||||
|
const POINT target_point = {static_cast<LONG>(origin.x),
|
||||||
|
static_cast<LONG>(origin.y)};
|
||||||
|
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||||
|
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||||
|
double scale_factor = dpi / 96.0;
|
||||||
|
|
||||||
|
HWND window = CreateWindow(
|
||||||
|
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
|
||||||
|
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
|
||||||
|
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
|
||||||
|
nullptr, nullptr, GetModuleHandle(nullptr), this);
|
||||||
|
|
||||||
|
if (!window) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateTheme(window);
|
||||||
|
|
||||||
|
return OnCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Win32Window::Show() {
|
||||||
|
return ShowWindow(window_handle_, SW_SHOWNORMAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// static
|
||||||
|
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept {
|
||||||
|
if (message == WM_NCCREATE) {
|
||||||
|
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
|
||||||
|
SetWindowLongPtr(window, GWLP_USERDATA,
|
||||||
|
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
|
||||||
|
|
||||||
|
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
|
||||||
|
EnableFullDpiSupportIfAvailable(window);
|
||||||
|
that->window_handle_ = window;
|
||||||
|
} else if (Win32Window* that = GetThisFromHandle(window)) {
|
||||||
|
return that->MessageHandler(window, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefWindowProc(window, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT
|
||||||
|
Win32Window::MessageHandler(HWND hwnd,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept {
|
||||||
|
switch (message) {
|
||||||
|
case WM_DESTROY:
|
||||||
|
window_handle_ = nullptr;
|
||||||
|
Destroy();
|
||||||
|
if (quit_on_close_) {
|
||||||
|
PostQuitMessage(0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case WM_DPICHANGED: {
|
||||||
|
auto newRectSize = reinterpret_cast<RECT*>(lparam);
|
||||||
|
LONG newWidth = newRectSize->right - newRectSize->left;
|
||||||
|
LONG newHeight = newRectSize->bottom - newRectSize->top;
|
||||||
|
|
||||||
|
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
|
||||||
|
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case WM_SIZE: {
|
||||||
|
RECT rect = GetClientArea();
|
||||||
|
if (child_content_ != nullptr) {
|
||||||
|
// Size and position the child window.
|
||||||
|
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
|
||||||
|
rect.bottom - rect.top, TRUE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
case WM_ACTIVATE:
|
||||||
|
if (child_content_ != nullptr) {
|
||||||
|
SetFocus(child_content_);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case WM_DWMCOLORIZATIONCOLORCHANGED:
|
||||||
|
UpdateTheme(hwnd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefWindowProc(window_handle_, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::Destroy() {
|
||||||
|
OnDestroy();
|
||||||
|
|
||||||
|
if (window_handle_) {
|
||||||
|
DestroyWindow(window_handle_);
|
||||||
|
window_handle_ = nullptr;
|
||||||
|
}
|
||||||
|
if (g_active_window_count == 0) {
|
||||||
|
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
|
||||||
|
return reinterpret_cast<Win32Window*>(
|
||||||
|
GetWindowLongPtr(window, GWLP_USERDATA));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::SetChildContent(HWND content) {
|
||||||
|
child_content_ = content;
|
||||||
|
SetParent(content, window_handle_);
|
||||||
|
RECT frame = GetClientArea();
|
||||||
|
|
||||||
|
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
|
||||||
|
frame.bottom - frame.top, true);
|
||||||
|
|
||||||
|
SetFocus(child_content_);
|
||||||
|
}
|
||||||
|
|
||||||
|
RECT Win32Window::GetClientArea() {
|
||||||
|
RECT frame;
|
||||||
|
GetClientRect(window_handle_, &frame);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
HWND Win32Window::GetHandle() {
|
||||||
|
return window_handle_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::SetQuitOnClose(bool quit_on_close) {
|
||||||
|
quit_on_close_ = quit_on_close;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Win32Window::OnCreate() {
|
||||||
|
// No-op; provided for subclasses.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::OnDestroy() {
|
||||||
|
// No-op; provided for subclasses.
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::UpdateTheme(HWND const window) {
|
||||||
|
DWORD light_mode;
|
||||||
|
DWORD light_mode_size = sizeof(light_mode);
|
||||||
|
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
|
||||||
|
kGetPreferredBrightnessRegValue,
|
||||||
|
RRF_RT_REG_DWORD, nullptr, &light_mode,
|
||||||
|
&light_mode_size);
|
||||||
|
|
||||||
|
if (result == ERROR_SUCCESS) {
|
||||||
|
BOOL enable_dark_mode = light_mode == 0;
|
||||||
|
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||||
|
&enable_dark_mode, sizeof(enable_dark_mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/flutter/windows/runner/win32_window.h
Normal file
102
apps/flutter/windows/runner/win32_window.h
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
#ifndef RUNNER_WIN32_WINDOW_H_
|
||||||
|
#define RUNNER_WIN32_WINDOW_H_
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
|
||||||
|
// inherited from by classes that wish to specialize with custom
|
||||||
|
// rendering and input handling
|
||||||
|
class Win32Window {
|
||||||
|
public:
|
||||||
|
struct Point {
|
||||||
|
unsigned int x;
|
||||||
|
unsigned int y;
|
||||||
|
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Size {
|
||||||
|
unsigned int width;
|
||||||
|
unsigned int height;
|
||||||
|
Size(unsigned int width, unsigned int height)
|
||||||
|
: width(width), height(height) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
Win32Window();
|
||||||
|
virtual ~Win32Window();
|
||||||
|
|
||||||
|
// Creates a win32 window with |title| that is positioned and sized using
|
||||||
|
// |origin| and |size|. New windows are created on the default monitor. Window
|
||||||
|
// sizes are specified to the OS in physical pixels, hence to ensure a
|
||||||
|
// consistent size this function will scale the inputted width and height as
|
||||||
|
// as appropriate for the default monitor. The window is invisible until
|
||||||
|
// |Show| is called. Returns true if the window was created successfully.
|
||||||
|
bool Create(const std::wstring& title, const Point& origin, const Size& size);
|
||||||
|
|
||||||
|
// Show the current window. Returns true if the window was successfully shown.
|
||||||
|
bool Show();
|
||||||
|
|
||||||
|
// Release OS resources associated with window.
|
||||||
|
void Destroy();
|
||||||
|
|
||||||
|
// Inserts |content| into the window tree.
|
||||||
|
void SetChildContent(HWND content);
|
||||||
|
|
||||||
|
// Returns the backing Window handle to enable clients to set icon and other
|
||||||
|
// window properties. Returns nullptr if the window has been destroyed.
|
||||||
|
HWND GetHandle();
|
||||||
|
|
||||||
|
// If true, closing this window will quit the application.
|
||||||
|
void SetQuitOnClose(bool quit_on_close);
|
||||||
|
|
||||||
|
// Return a RECT representing the bounds of the current client area.
|
||||||
|
RECT GetClientArea();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Processes and route salient window messages for mouse handling,
|
||||||
|
// size change and DPI. Delegates handling of these to member overloads that
|
||||||
|
// inheriting classes can handle.
|
||||||
|
virtual LRESULT MessageHandler(HWND window,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept;
|
||||||
|
|
||||||
|
// Called when CreateAndShow is called, allowing subclass window-related
|
||||||
|
// setup. Subclasses should return false if setup fails.
|
||||||
|
virtual bool OnCreate();
|
||||||
|
|
||||||
|
// Called when Destroy is called.
|
||||||
|
virtual void OnDestroy();
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class WindowClassRegistrar;
|
||||||
|
|
||||||
|
// OS callback called by message pump. Handles the WM_NCCREATE message which
|
||||||
|
// is passed when the non-client area is being created and enables automatic
|
||||||
|
// non-client DPI scaling so that the non-client area automatically
|
||||||
|
// responds to changes in DPI. All other messages are handled by
|
||||||
|
// MessageHandler.
|
||||||
|
static LRESULT CALLBACK WndProc(HWND const window,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept;
|
||||||
|
|
||||||
|
// Retrieves a class instance pointer for |window|
|
||||||
|
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
|
||||||
|
|
||||||
|
// Update the window frame's theme to match the system theme.
|
||||||
|
static void UpdateTheme(HWND const window);
|
||||||
|
|
||||||
|
bool quit_on_close_ = false;
|
||||||
|
|
||||||
|
// window handle for top level window.
|
||||||
|
HWND window_handle_ = nullptr;
|
||||||
|
|
||||||
|
// window handle for hosted content.
|
||||||
|
HWND child_content_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // RUNNER_WIN32_WINDOW_H_
|
||||||
4
apps/tauri/.gitignore
vendored
Normal file
4
apps/tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
12
apps/tauri/index.html
Normal file
12
apps/tauri/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Bevy Tasks</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2275
apps/tauri/package-lock.json
generated
Normal file
2275
apps/tauri/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
apps/tauri/package.json
Normal file
25
apps/tauri/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "bevy-tasks",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/tauri/src-tauri/Cargo.toml
Normal file
26
apps/tauri/src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "bevy-tasks-tauri"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "bevy_tasks_tauri_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
bevy-tasks-core = { path = "../../../crates/bevy-tasks-core" }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
uuid = { version = "1", features = ["serde", "v4"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
[package.metadata.tauri]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
3
apps/tauri/src-tauri/build.rs
Normal file
3
apps/tauri/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
BIN
apps/tauri/src-tauri/icons/128x128.png
Normal file
BIN
apps/tauri/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 686 B |
BIN
apps/tauri/src-tauri/icons/128x128@2x.png
Normal file
BIN
apps/tauri/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/tauri/src-tauri/icons/32x32.png
Normal file
BIN
apps/tauri/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 B |
BIN
apps/tauri/src-tauri/icons/icon.icns
Normal file
BIN
apps/tauri/src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
apps/tauri/src-tauri/icons/icon.ico
Normal file
BIN
apps/tauri/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 B |
376
apps/tauri/src-tauri/src/lib.rs
Normal file
376
apps/tauri/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use bevy_tasks_core::{
|
||||||
|
config::{AppConfig, WorkspaceConfig},
|
||||||
|
models::{Task, TaskList, TaskStatus},
|
||||||
|
repository::TaskRepository,
|
||||||
|
sync::{self, SyncMode, SyncResult as CoreSyncResult},
|
||||||
|
webdav,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shared application state behind a mutex.
|
||||||
|
struct AppState {
|
||||||
|
config: AppConfig,
|
||||||
|
repo: Option<TaskRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable sync result for the frontend.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
struct SyncResult {
|
||||||
|
uploaded: u32,
|
||||||
|
downloaded: u32,
|
||||||
|
deleted_local: u32,
|
||||||
|
deleted_remote: u32,
|
||||||
|
conflicts: u32,
|
||||||
|
errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CoreSyncResult> for SyncResult {
|
||||||
|
fn from(r: CoreSyncResult) -> Self {
|
||||||
|
Self {
|
||||||
|
uploaded: r.uploaded,
|
||||||
|
downloaded: r.downloaded,
|
||||||
|
deleted_local: r.deleted_local,
|
||||||
|
deleted_remote: r.deleted_remote,
|
||||||
|
conflicts: r.conflicts,
|
||||||
|
errors: r.errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: get or open a TaskRepository for the current workspace.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config commands ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_config(state: State<'_, Mutex<AppState>>) -> Result<AppConfig, String> {
|
||||||
|
let s = state.lock().unwrap();
|
||||||
|
Ok(s.config.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn save_config(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
|
||||||
|
let s = state.lock().unwrap();
|
||||||
|
let path = AppConfig::get_config_path();
|
||||||
|
s.config.save_to_file(&path).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn add_workspace(
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> 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())?;
|
||||||
|
// Reset repo so it reopens on next access
|
||||||
|
s.repo = None;
|
||||||
|
let config_path = AppConfig::get_config_path();
|
||||||
|
s.config
|
||||||
|
.save_to_file(&config_path)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_current_workspace(
|
||||||
|
name: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn remove_workspace(
|
||||||
|
name: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workspace init ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn init_workspace(path: String) -> Result<(), String> {
|
||||||
|
TaskRepository::init(PathBuf::from(path))
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List commands ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_lists(state: State<'_, Mutex<AppState>>) -> Result<Vec<TaskList>, String> {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
ensure_repo(&mut s)?;
|
||||||
|
s.repo
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.get_lists()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_list(
|
||||||
|
name: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> Result<TaskList, String> {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
ensure_repo(&mut s)?;
|
||||||
|
s.repo
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.create_list(name)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_list(
|
||||||
|
list_id: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> 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 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_tasks(
|
||||||
|
list_id: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> Result<Vec<Task>, 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_ref()
|
||||||
|
.unwrap()
|
||||||
|
.list_tasks(id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_task(
|
||||||
|
list_id: String,
|
||||||
|
title: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> Result<Task, 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 task = Task::new(title);
|
||||||
|
s.repo
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.create_task(id, task)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_task(
|
||||||
|
list_id: String,
|
||||||
|
task: Task,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> 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()
|
||||||
|
.update_task(id, task)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_task(
|
||||||
|
list_id: String,
|
||||||
|
task_id: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn toggle_task(
|
||||||
|
list_id: String,
|
||||||
|
task_id: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> Result<Task, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn reorder_task(
|
||||||
|
list_id: String,
|
||||||
|
task_id: String,
|
||||||
|
new_position: usize,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> 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)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync commands ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_webdav_config(
|
||||||
|
workspace_name: String,
|
||||||
|
webdav_url: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
|
||||||
|
ws.webdav_url = Some(webdav_url);
|
||||||
|
}
|
||||||
|
let config_path = AppConfig::get_config_path();
|
||||||
|
s.config
|
||||||
|
.save_to_file(&config_path)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn store_credentials(
|
||||||
|
domain: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn test_webdav_connection(
|
||||||
|
url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let client = bevy_tasks_core::webdav::WebDavClient::new(&url, &username, &password);
|
||||||
|
client
|
||||||
|
.test_connection()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn sync_workspace(
|
||||||
|
workspace_path: String,
|
||||||
|
webdav_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<SyncResult, String> {
|
||||||
|
let result = sync::sync_workspace(
|
||||||
|
&PathBuf::from(workspace_path),
|
||||||
|
&webdav_url,
|
||||||
|
&username,
|
||||||
|
&password,
|
||||||
|
SyncMode::Full,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(result.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App entry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
// Load or create config
|
||||||
|
let config_path = AppConfig::get_config_path();
|
||||||
|
let config = AppConfig::load_from_file(&config_path).unwrap_or_default();
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(Mutex::new(AppState { config, repo: None }))
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
get_config,
|
||||||
|
save_config,
|
||||||
|
add_workspace,
|
||||||
|
set_current_workspace,
|
||||||
|
remove_workspace,
|
||||||
|
init_workspace,
|
||||||
|
get_lists,
|
||||||
|
create_list,
|
||||||
|
delete_list,
|
||||||
|
list_tasks,
|
||||||
|
create_task,
|
||||||
|
update_task,
|
||||||
|
delete_task,
|
||||||
|
toggle_task,
|
||||||
|
reorder_task,
|
||||||
|
set_webdav_config,
|
||||||
|
store_credentials,
|
||||||
|
test_webdav_connection,
|
||||||
|
sync_workspace,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
apps/tauri/src-tauri/src/main.rs
Normal file
6
apps/tauri/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
bevy_tasks_tauri_lib::run()
|
||||||
|
}
|
||||||
40
apps/tauri/src-tauri/tauri.conf.json
Normal file
40
apps/tauri/src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/nicegui-org/nicegui/v2/tauri-conf-schema.json",
|
||||||
|
"productName": "Bevy Tasks",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.bevytasks.app",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": false,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Bevy Tasks",
|
||||||
|
"width": 400,
|
||||||
|
"height": 700,
|
||||||
|
"minWidth": 320,
|
||||||
|
"minHeight": 500,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {}
|
||||||
|
}
|
||||||
34
apps/tauri/src/App.svelte
Normal file
34
apps/tauri/src/App.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { app } from "./lib/stores/app.svelte";
|
||||||
|
import SetupScreen from "./lib/screens/SetupScreen.svelte";
|
||||||
|
import TasksScreen from "./lib/screens/TasksScreen.svelte";
|
||||||
|
import SettingsScreen from "./lib/screens/SettingsScreen.svelte";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
app.loadConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={app.darkMode ? "dark" : ""}>
|
||||||
|
<div
|
||||||
|
class="h-screen w-screen overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark"
|
||||||
|
>
|
||||||
|
{#if app.error}
|
||||||
|
<div
|
||||||
|
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between bg-danger px-4 py-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
<span>{app.error}</span>
|
||||||
|
<button onclick={() => app.clearError()} class="ml-2 font-bold">✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if app.screen === "setup"}
|
||||||
|
<SetupScreen />
|
||||||
|
{:else if app.screen === "tasks"}
|
||||||
|
<TasksScreen />
|
||||||
|
{:else if app.screen === "settings"}
|
||||||
|
<SettingsScreen />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
44
apps/tauri/src/app.css
Normal file
44
apps/tauri/src/app.css
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-hover: #1d4ed8;
|
||||||
|
--color-surface-light: #ffffff;
|
||||||
|
--color-surface-dark: #121212;
|
||||||
|
--color-card-light: #f9fafb;
|
||||||
|
--color-card-dark: #1e1e1e;
|
||||||
|
--color-text-light: #1f2937;
|
||||||
|
--color-text-dark: #e5e7eb;
|
||||||
|
--color-text-secondary-light: #6b7280;
|
||||||
|
--color-text-secondary-dark: #9ca3af;
|
||||||
|
--color-border-light: #e5e7eb;
|
||||||
|
--color-border-dark: #374151;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: "Noto Sans", system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
37
apps/tauri/src/lib/components/BottomSheet.svelte
Normal file
37
apps/tauri/src/lib/components/BottomSheet.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { onclose, children }: { onclose: () => void; children: any } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-black/40"
|
||||||
|
onclick={onclose}
|
||||||
|
onkeydown={(e) => { if (e.key === "Escape") onclose(); }}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Sheet -->
|
||||||
|
<div
|
||||||
|
class="fixed bottom-0 left-0 right-0 z-50 max-h-[70vh] overflow-y-auto rounded-t-2xl bg-surface-light shadow-xl dark:bg-card-dark animate-slide-up"
|
||||||
|
>
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div class="flex justify-center py-2">
|
||||||
|
<div class="h-1 w-8 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
{@render children()}
|
||||||
|
<div class="h-[env(safe-area-inset-bottom)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.25s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
apps/tauri/src/lib/components/NewTaskInput.svelte
Normal file
37
apps/tauri/src/lib/components/NewTaskInput.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { app } from "../stores/app.svelte";
|
||||||
|
|
||||||
|
let title = $state("");
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
await app.createTask(title.trim());
|
||||||
|
title = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-t border-border-light bg-surface-light px-4 py-3 dark:border-border-dark dark:bg-surface-dark"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder={app.activeListId ? "Add a task…" : "Select a list first"}
|
||||||
|
disabled={!app.activeListId}
|
||||||
|
class="min-w-0 flex-1 rounded-xl border border-border-light bg-card-light px-4 py-2.5 text-sm outline-none placeholder:opacity-40 focus:border-primary disabled:opacity-30 dark:border-border-dark dark:bg-card-dark"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!title.trim() || !app.activeListId}
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-md transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:shadow-none"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
161
apps/tauri/src/lib/components/TaskItem.svelte
Normal file
161
apps/tauri/src/lib/components/TaskItem.svelte
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Task } from "../types";
|
||||||
|
import { app } from "../stores/app.svelte";
|
||||||
|
|
||||||
|
let { task }: { task: Task } = $props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let editTitle = $state(task.title);
|
||||||
|
let editDesc = $state(task.description);
|
||||||
|
let touchStartX = $state(0);
|
||||||
|
let swipeX = $state(0);
|
||||||
|
let swiping = $state(false);
|
||||||
|
|
||||||
|
let isCompleted = $derived(task.status === "completed");
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
swiping = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!swiping) return;
|
||||||
|
const dx = e.touches[0].clientX - touchStartX;
|
||||||
|
// Only allow left swipe for pending, right swipe for completed
|
||||||
|
if (isCompleted) swipeX = Math.max(0, dx);
|
||||||
|
else swipeX = Math.min(0, dx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
if (Math.abs(swipeX) > 100) {
|
||||||
|
app.toggleTask(task.id);
|
||||||
|
}
|
||||||
|
swipeX = 0;
|
||||||
|
swiping = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editTitle.trim()) return;
|
||||||
|
const updated = { ...task, title: editTitle.trim(), description: editDesc };
|
||||||
|
await app.updateTask(updated);
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
if (d.toDateString() === today.toDateString()) return "Today";
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow";
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden border-b border-border-light dark:border-border-dark"
|
||||||
|
ontouchstart={handleTouchStart}
|
||||||
|
ontouchmove={handleTouchMove}
|
||||||
|
ontouchend={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<!-- Swipe background -->
|
||||||
|
{#if swipeX !== 0}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center {swipeX < 0 ? 'justify-end' : 'justify-start'} bg-primary px-4 text-white"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{isCompleted ? "Undo" : "Complete"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Task content -->
|
||||||
|
<div
|
||||||
|
class="relative flex items-start gap-3 bg-surface-light px-4 py-3 dark:bg-surface-dark"
|
||||||
|
style="transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
|
||||||
|
>
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<button
|
||||||
|
onclick={() => app.toggleTask(task.id)}
|
||||||
|
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors {isCompleted
|
||||||
|
? 'border-primary bg-primary'
|
||||||
|
: 'border-gray-400 dark:border-gray-500'}"
|
||||||
|
>
|
||||||
|
{#if isCompleted}
|
||||||
|
<svg class="h-3 w-3 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
{#if editing}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editTitle}
|
||||||
|
class="w-full bg-transparent text-sm font-medium outline-none"
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") saveEdit(); if (e.key === "Escape") editing = false; }}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
bind:value={editDesc}
|
||||||
|
placeholder="Add description…"
|
||||||
|
rows="2"
|
||||||
|
class="mt-1 w-full resize-none bg-transparent text-xs opacity-60 outline-none"
|
||||||
|
/>
|
||||||
|
<div class="mt-1 flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={saveEdit}
|
||||||
|
class="rounded px-2 py-1 text-xs font-medium text-primary"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (editing = false)}
|
||||||
|
class="rounded px-2 py-1 text-xs opacity-60"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => { editing = true; editTitle = task.title; editDesc = task.description; }}
|
||||||
|
class="min-w-0 flex-1 text-left"
|
||||||
|
>
|
||||||
|
<p class="text-sm {isCompleted ? 'line-through opacity-50' : 'font-medium'}">
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
{#if task.description}
|
||||||
|
<p class="mt-0.5 text-xs opacity-40 line-clamp-1">{task.description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if task.due_date}
|
||||||
|
<span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark">
|
||||||
|
{formatDate(task.due_date)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
{#if !editing}
|
||||||
|
<button
|
||||||
|
onclick={() => app.deleteTask(task.id)}
|
||||||
|
class="shrink-0 rounded p-1 opacity-0 transition-opacity hover:opacity-60 group-hover:opacity-30"
|
||||||
|
style="opacity: 0.15"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
209
apps/tauri/src/lib/screens/SettingsScreen.svelte
Normal file
209
apps/tauri/src/lib/screens/SettingsScreen.svelte
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { app } from "../stores/app.svelte";
|
||||||
|
|
||||||
|
let webdavUrl = $state("");
|
||||||
|
let webdavUser = $state("");
|
||||||
|
let webdavPass = $state("");
|
||||||
|
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
||||||
|
let confirmRemove = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
testStatus = "testing";
|
||||||
|
try {
|
||||||
|
await (globalThis as any).__TAURI_INTERNALS__.invoke("test_webdav_connection", {
|
||||||
|
url: webdavUrl,
|
||||||
|
username: webdavUser,
|
||||||
|
password: webdavPass,
|
||||||
|
});
|
||||||
|
testStatus = "ok";
|
||||||
|
} catch {
|
||||||
|
testStatus = "fail";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWebdav() {
|
||||||
|
if (!app.config?.current_workspace || !webdavUrl.trim()) return;
|
||||||
|
await (globalThis as any).__TAURI_INTERNALS__.invoke("set_webdav_config", {
|
||||||
|
workspaceName: app.config.current_workspace,
|
||||||
|
webdavUrl: webdavUrl.trim(),
|
||||||
|
});
|
||||||
|
if (webdavUser && webdavPass) {
|
||||||
|
const domain = new URL(webdavUrl).hostname;
|
||||||
|
await (globalThis as any).__TAURI_INTERNALS__.invoke("store_credentials", {
|
||||||
|
domain,
|
||||||
|
username: webdavUser,
|
||||||
|
password: webdavPass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveWorkspace(name: string) {
|
||||||
|
await app.removeWorkspace(name);
|
||||||
|
confirmRemove = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="flex items-center gap-3 border-b border-border-light px-4 py-3 dark:border-border-dark"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => app.setScreen("tasks")}
|
||||||
|
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-lg font-bold">Settings</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="overflow-y-auto p-4" style="height: calc(100vh - 57px)">
|
||||||
|
<!-- Workspaces -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||||
|
Workspaces
|
||||||
|
</h2>
|
||||||
|
{#if app.config}
|
||||||
|
{#each Object.entries(app.config.workspaces) as [name, ws]}
|
||||||
|
<div
|
||||||
|
class="mb-2 rounded-xl border border-border-light p-3 dark:border-border-dark"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium {name === app.config.current_workspace ? 'text-primary' : ''}">
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs opacity-50">{ws.path}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#if name !== app.config.current_workspace}
|
||||||
|
<button
|
||||||
|
onclick={() => app.switchWorkspace(name)}
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs font-medium text-primary hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
Switch
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if confirmRemove === name}
|
||||||
|
<button
|
||||||
|
onclick={() => handleRemoveWorkspace(name)}
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs font-medium text-danger"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmRemove = null)}
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs opacity-60"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmRemove = name)}
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs opacity-40 hover:text-danger hover:opacity-100"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if ws.webdav_url}
|
||||||
|
<p class="mt-1 text-xs opacity-40">Sync: {ws.webdav_url}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ws.last_sync}
|
||||||
|
<p class="text-xs opacity-40">
|
||||||
|
Last synced: {new Date(ws.last_sync).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => app.setScreen("setup")}
|
||||||
|
class="mt-2 rounded-lg px-3 py-2 text-sm text-primary hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
+ Add workspace
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- WebDAV Sync -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||||
|
WebDAV Sync
|
||||||
|
</h2>
|
||||||
|
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
||||||
|
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
bind:value={webdavUrl}
|
||||||
|
placeholder="https://dav.example.com/tasks/"
|
||||||
|
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="mb-1 block text-xs font-medium opacity-60">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={webdavUser}
|
||||||
|
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="mb-1 block text-xs font-medium opacity-60">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={webdavPass}
|
||||||
|
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={testConnection}
|
||||||
|
disabled={!webdavUrl.trim()}
|
||||||
|
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{testStatus === "testing" ? "Testing…" : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed — Retry" : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={saveWebdav}
|
||||||
|
disabled={!webdavUrl.trim()}
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if app.config?.current_workspace}
|
||||||
|
<button
|
||||||
|
onclick={() => app.triggerSync()}
|
||||||
|
disabled={app.syncing}
|
||||||
|
class="mt-3 w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{app.syncing ? "Syncing…" : "Sync Now"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Theme -->
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||||
|
Appearance
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={() => app.toggleDarkMode()}
|
||||||
|
class="flex w-full items-center justify-between rounded-xl border border-border-light p-4 dark:border-border-dark"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">Dark mode</span>
|
||||||
|
<div
|
||||||
|
class="h-6 w-11 rounded-full transition-colors {app.darkMode ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-5 w-5 translate-y-0.5 rounded-full bg-white shadow transition-transform {app.darkMode ? 'translate-x-5.5' : 'translate-x-0.5'}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
64
apps/tauri/src/lib/screens/SetupScreen.svelte
Normal file
64
apps/tauri/src/lib/screens/SetupScreen.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { app } from "../stores/app.svelte";
|
||||||
|
|
||||||
|
let name = $state("");
|
||||||
|
let path = $state("");
|
||||||
|
|
||||||
|
async function pickFolder() {
|
||||||
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
if (selected) path = selected as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!name.trim() || !path.trim()) return;
|
||||||
|
await app.addWorkspace(name.trim(), path.trim());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full items-center justify-center p-6">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark"
|
||||||
|
>
|
||||||
|
<h1 class="mb-1 text-2xl font-bold">Bevy Tasks</h1>
|
||||||
|
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
|
Create or open a workspace to get started.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="mb-1 block text-sm font-medium">
|
||||||
|
Workspace name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
placeholder="My Tasks"
|
||||||
|
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class="mb-1 block text-sm font-medium">Folder</label>
|
||||||
|
<div class="mb-6 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={path}
|
||||||
|
readonly
|
||||||
|
placeholder="Select a folder…"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={pickFolder}
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={handleCreate}
|
||||||
|
disabled={!name.trim() || !path.trim()}
|
||||||
|
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Create Workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
207
apps/tauri/src/lib/screens/TasksScreen.svelte
Normal file
207
apps/tauri/src/lib/screens/TasksScreen.svelte
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { app } from "../stores/app.svelte";
|
||||||
|
import TaskItem from "../components/TaskItem.svelte";
|
||||||
|
import BottomSheet from "../components/BottomSheet.svelte";
|
||||||
|
import NewTaskInput from "../components/NewTaskInput.svelte";
|
||||||
|
|
||||||
|
let showListSheet = $state(false);
|
||||||
|
let showNewList = $state(false);
|
||||||
|
let newListName = $state("");
|
||||||
|
let showCompleted = $state(true);
|
||||||
|
let confirmDeleteList = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function handleNewList() {
|
||||||
|
if (!newListName.trim()) return;
|
||||||
|
await app.createList(newListName.trim());
|
||||||
|
newListName = "";
|
||||||
|
showNewList = false;
|
||||||
|
showListSheet = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteList(id: string) {
|
||||||
|
await app.deleteList(id);
|
||||||
|
confirmDeleteList = null;
|
||||||
|
showListSheet = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header
|
||||||
|
class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
onclick={() => (showListSheet = true)}
|
||||||
|
class="flex items-center gap-1 text-lg font-bold"
|
||||||
|
>
|
||||||
|
{app.activeList?.title ?? "Tasks"}
|
||||||
|
<svg class="h-4 w-4 opacity-50" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
|
{app.config?.current_workspace ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if app.syncing}
|
||||||
|
<div class="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => app.toggleDarkMode()}
|
||||||
|
class="rounded-lg p-2 hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
title="Toggle theme"
|
||||||
|
>
|
||||||
|
{#if app.darkMode}
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => app.setScreen("settings")}
|
||||||
|
class="rounded-lg p-2 hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Task list -->
|
||||||
|
<main class="flex-1 overflow-y-auto" style="height: calc(100vh - 57px - 72px)">
|
||||||
|
{#if app.lists.length === 0}
|
||||||
|
<div class="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||||
|
<p class="text-lg font-medium opacity-60">No lists yet</p>
|
||||||
|
<p class="mt-1 text-sm opacity-40">Tap the list name above to create one</p>
|
||||||
|
</div>
|
||||||
|
{:else if !app.activeListId}
|
||||||
|
<div class="flex h-full items-center justify-center opacity-40">
|
||||||
|
Select a list
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Pending tasks -->
|
||||||
|
{#each app.pendingTasks as task (task.id)}
|
||||||
|
<TaskItem {task} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if app.pendingTasks.length === 0}
|
||||||
|
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Completed section -->
|
||||||
|
{#if app.completedTasks.length > 0}
|
||||||
|
<button
|
||||||
|
onclick={() => (showCompleted = !showCompleted)}
|
||||||
|
class="flex w-full items-center gap-2 border-t border-border-light px-4 py-3 text-sm font-medium text-text-secondary-light dark:border-border-dark dark:text-text-secondary-dark"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 transition-transform {showCompleted ? 'rotate-90' : ''}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Completed ({app.completedTasks.length})
|
||||||
|
</button>
|
||||||
|
{#if showCompleted}
|
||||||
|
{#each app.completedTasks as task (task.id)}
|
||||||
|
<TaskItem {task} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- New task input (bottom bar) -->
|
||||||
|
<NewTaskInput />
|
||||||
|
|
||||||
|
<!-- List picker bottom sheet -->
|
||||||
|
{#if showListSheet}
|
||||||
|
<BottomSheet onclose={() => { showListSheet = false; showNewList = false; }}>
|
||||||
|
<div class="px-4 pb-2 pt-4">
|
||||||
|
<h2 class="mb-3 text-lg font-bold">Lists</h2>
|
||||||
|
{#each app.lists as list (list.id)}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
onclick={() => { app.selectList(list.id); showListSheet = false; }}
|
||||||
|
class="flex-1 rounded-lg px-3 py-2.5 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10 {list.id === app.activeListId ? 'font-bold text-primary' : ''}"
|
||||||
|
>
|
||||||
|
{list.title}
|
||||||
|
</button>
|
||||||
|
{#if confirmDeleteList === list.id}
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteList(list.id)}
|
||||||
|
class="rounded px-2 py-1 text-xs font-medium text-danger hover:bg-danger/10"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmDeleteList = null)}
|
||||||
|
class="rounded px-2 py-1 text-xs opacity-60 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmDeleteList = list.id)}
|
||||||
|
class="rounded p-1.5 opacity-30 hover:opacity-60"
|
||||||
|
title="Delete list"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if showNewList}
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newListName}
|
||||||
|
placeholder="List name"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") handleNewList(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={handleNewList}
|
||||||
|
disabled={!newListName.trim()}
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (showNewList = true)}
|
||||||
|
class="mt-2 w-full rounded-lg px-3 py-2.5 text-left text-sm text-primary hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
+ New list
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
{/if}
|
||||||
285
apps/tauri/src/lib/stores/app.svelte.ts
Normal file
285
apps/tauri/src/lib/stores/app.svelte.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type {
|
||||||
|
AppConfig,
|
||||||
|
Task,
|
||||||
|
TaskList,
|
||||||
|
Screen,
|
||||||
|
SyncResult,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
// ── Reactive state ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let screen = $state<Screen>("setup");
|
||||||
|
let config = $state<AppConfig | null>(null);
|
||||||
|
let lists = $state<TaskList[]>([]);
|
||||||
|
let activeListId = $state<string | null>(null);
|
||||||
|
let tasks = $state<Task[]>([]);
|
||||||
|
let darkMode = $state(
|
||||||
|
globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
|
||||||
|
);
|
||||||
|
let syncing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// ── Derived ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null);
|
||||||
|
let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog"));
|
||||||
|
let completedTasks = $derived(tasks.filter((t) => t.status === "completed"));
|
||||||
|
let hasWorkspace = $derived(
|
||||||
|
config !== null &&
|
||||||
|
config.current_workspace !== null &&
|
||||||
|
Object.keys(config.workspaces).length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
config = await invoke<AppConfig>("get_config");
|
||||||
|
if (hasWorkspace) {
|
||||||
|
screen = "tasks";
|
||||||
|
await loadLists();
|
||||||
|
} else {
|
||||||
|
screen = "setup";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
config = { workspaces: {}, current_workspace: null };
|
||||||
|
screen = "setup";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addWorkspace(name: string, path: string) {
|
||||||
|
try {
|
||||||
|
await invoke("init_workspace", { path });
|
||||||
|
await invoke("add_workspace", { name, path });
|
||||||
|
config = await invoke<AppConfig>("get_config");
|
||||||
|
await loadLists();
|
||||||
|
screen = "tasks";
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchWorkspace(name: string) {
|
||||||
|
try {
|
||||||
|
await invoke("set_current_workspace", { name });
|
||||||
|
config = await invoke<AppConfig>("get_config");
|
||||||
|
activeListId = null;
|
||||||
|
await loadLists();
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWorkspace(name: string) {
|
||||||
|
try {
|
||||||
|
await invoke("remove_workspace", { name });
|
||||||
|
config = await invoke<AppConfig>("get_config");
|
||||||
|
if (!hasWorkspace) {
|
||||||
|
screen = "setup";
|
||||||
|
lists = [];
|
||||||
|
tasks = [];
|
||||||
|
activeListId = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLists() {
|
||||||
|
try {
|
||||||
|
lists = await invoke<TaskList[]>("get_lists");
|
||||||
|
if (lists.length > 0 && !activeListId) {
|
||||||
|
activeListId = lists[0].id;
|
||||||
|
}
|
||||||
|
if (activeListId) await loadTasks();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
if (!activeListId) return;
|
||||||
|
try {
|
||||||
|
tasks = await invoke<Task[]>("list_tasks", { listId: activeListId });
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectList(id: string) {
|
||||||
|
activeListId = id;
|
||||||
|
await loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createList(name: string) {
|
||||||
|
try {
|
||||||
|
const list = await invoke<TaskList>("create_list", { name });
|
||||||
|
lists = [...lists, list];
|
||||||
|
activeListId = list.id;
|
||||||
|
tasks = [];
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteList(id: string) {
|
||||||
|
try {
|
||||||
|
await invoke("delete_list", { listId: id });
|
||||||
|
lists = lists.filter((l) => l.id !== id);
|
||||||
|
if (activeListId === id) {
|
||||||
|
activeListId = lists.length > 0 ? lists[0].id : null;
|
||||||
|
if (activeListId) await loadTasks();
|
||||||
|
else tasks = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask(title: string) {
|
||||||
|
if (!activeListId) return;
|
||||||
|
try {
|
||||||
|
const task = await invoke<Task>("create_task", {
|
||||||
|
listId: activeListId,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
tasks = [...tasks, task];
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTask(taskId: string) {
|
||||||
|
if (!activeListId) return;
|
||||||
|
try {
|
||||||
|
const updated = await invoke<Task>("toggle_task", {
|
||||||
|
listId: activeListId,
|
||||||
|
taskId,
|
||||||
|
});
|
||||||
|
tasks = tasks.map((t) => (t.id === taskId ? updated : t));
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTask(task: Task) {
|
||||||
|
if (!activeListId) return;
|
||||||
|
try {
|
||||||
|
await invoke("update_task", { listId: activeListId, task });
|
||||||
|
tasks = tasks.map((t) => (t.id === task.id ? task : t));
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(taskId: string) {
|
||||||
|
if (!activeListId) return;
|
||||||
|
try {
|
||||||
|
await invoke("delete_task", { listId: activeListId, taskId });
|
||||||
|
tasks = tasks.filter((t) => t.id !== taskId);
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerSync() {
|
||||||
|
if (!config?.current_workspace) return;
|
||||||
|
const ws = config.workspaces[config.current_workspace];
|
||||||
|
if (!ws?.webdav_url) {
|
||||||
|
error = "No WebDAV URL configured";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncing = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const result = await invoke<SyncResult>("sync_workspace", {
|
||||||
|
workspacePath: ws.path,
|
||||||
|
webdavUrl: ws.webdav_url,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
error = result.errors.join("; ");
|
||||||
|
}
|
||||||
|
await loadLists();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDarkMode() {
|
||||||
|
darkMode = !darkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScreen(s: Screen) {
|
||||||
|
screen = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exports ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const app = {
|
||||||
|
get screen() {
|
||||||
|
return screen;
|
||||||
|
},
|
||||||
|
get config() {
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
get lists() {
|
||||||
|
return lists;
|
||||||
|
},
|
||||||
|
get activeListId() {
|
||||||
|
return activeListId;
|
||||||
|
},
|
||||||
|
get activeList() {
|
||||||
|
return activeList;
|
||||||
|
},
|
||||||
|
get tasks() {
|
||||||
|
return tasks;
|
||||||
|
},
|
||||||
|
get pendingTasks() {
|
||||||
|
return pendingTasks;
|
||||||
|
},
|
||||||
|
get completedTasks() {
|
||||||
|
return completedTasks;
|
||||||
|
},
|
||||||
|
get darkMode() {
|
||||||
|
return darkMode;
|
||||||
|
},
|
||||||
|
get syncing() {
|
||||||
|
return syncing;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
get hasWorkspace() {
|
||||||
|
return hasWorkspace;
|
||||||
|
},
|
||||||
|
loadConfig,
|
||||||
|
addWorkspace,
|
||||||
|
switchWorkspace,
|
||||||
|
removeWorkspace,
|
||||||
|
loadLists,
|
||||||
|
loadTasks,
|
||||||
|
selectList,
|
||||||
|
createList,
|
||||||
|
deleteList,
|
||||||
|
createTask,
|
||||||
|
toggleTask,
|
||||||
|
updateTask,
|
||||||
|
deleteTask,
|
||||||
|
triggerSync,
|
||||||
|
toggleDarkMode,
|
||||||
|
setScreen,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
41
apps/tauri/src/lib/types.ts
Normal file
41
apps/tauri/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: "backlog" | "completed";
|
||||||
|
due_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskList {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
tasks: Task[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
group_by_due_date: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceConfig {
|
||||||
|
path: string;
|
||||||
|
webdav_url: string | null;
|
||||||
|
last_sync: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
workspaces: Record<string, WorkspaceConfig>;
|
||||||
|
current_workspace: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
uploaded: number;
|
||||||
|
downloaded: number;
|
||||||
|
deleted_local: number;
|
||||||
|
deleted_remote: number;
|
||||||
|
conflicts: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Screen = "setup" | "tasks" | "settings";
|
||||||
7
apps/tauri/src/main.ts
Normal file
7
apps/tauri/src/main.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
import "./app.css";
|
||||||
|
|
||||||
|
const app = mount(App, { target: document.getElementById("app")! });
|
||||||
|
|
||||||
|
export default app;
|
||||||
5
apps/tauri/svelte.config.js
Normal file
5
apps/tauri/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
16
apps/tauri/tsconfig.json
Normal file
16
apps/tauri/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
17
apps/tauri/vite.config.ts
Normal file
17
apps/tauri/vite.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte(), tailwindcss()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||||
|
watch: { ignored: ["**/src-tauri/**"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ use crate::models::{Task, TaskList};
|
||||||
use crate::storage::{FileSystemStorage, Storage};
|
use crate::storage::{FileSystemStorage, Storage};
|
||||||
|
|
||||||
pub struct TaskRepository {
|
pub struct TaskRepository {
|
||||||
storage: Box<dyn Storage>,
|
storage: Box<dyn Storage + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TaskRepository {
|
impl TaskRepository {
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@ impl SyncState {
|
||||||
// --- Sync Executor ---
|
// --- Sync Executor ---
|
||||||
|
|
||||||
/// Callback type for sync progress reporting.
|
/// Callback type for sync progress reporting.
|
||||||
pub type ProgressCallback = Box<dyn Fn(&str) + Send>;
|
pub type ProgressCallback = Box<dyn Fn(&str) + Send + Sync>;
|
||||||
|
|
||||||
/// Execute a full sync between a local workspace and a remote WebDAV server.
|
/// Execute a full sync between a local workspace and a remote WebDAV server.
|
||||||
pub async fn sync_workspace(
|
pub async fn sync_workspace(
|
||||||
|
|
@ -557,7 +557,7 @@ async fn execute_action(
|
||||||
workspace_path: &Path,
|
workspace_path: &Path,
|
||||||
action: &SyncAction,
|
action: &SyncAction,
|
||||||
sync_state: &mut SyncState,
|
sync_state: &mut SyncState,
|
||||||
report: &dyn Fn(&str),
|
report: &(dyn Fn(&str) + Send + Sync),
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match action {
|
match action {
|
||||||
SyncAction::Upload { path } | SyncAction::ConflictLocalWins { path } => {
|
SyncAction::Upload { path } | SyncAction::ConflictLocalWins { path } => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue