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-gui",
|
||||
]
|
||||
exclude = [
|
||||
"apps/tauri/src-tauri",
|
||||
"apps/flutter/rust",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[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};
|
||||
|
||||
pub struct TaskRepository {
|
||||
storage: Box<dyn Storage>,
|
||||
storage: Box<dyn Storage + Send + Sync>,
|
||||
}
|
||||
|
||||
impl TaskRepository {
|
||||
|
|
|
|||
|
|
@ -459,7 +459,7 @@ impl SyncState {
|
|||
// --- Sync Executor ---
|
||||
|
||||
/// 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.
|
||||
pub async fn sync_workspace(
|
||||
|
|
@ -557,7 +557,7 @@ async fn execute_action(
|
|||
workspace_path: &Path,
|
||||
action: &SyncAction,
|
||||
sync_state: &mut SyncState,
|
||||
report: &dyn Fn(&str),
|
||||
report: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<()> {
|
||||
match action {
|
||||
SyncAction::Upload { path } | SyncAction::ConflictLocalWins { path } => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue