diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart new file mode 100644 index 0000000..af9e77b --- /dev/null +++ b/apps/flutter/lib/main.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:window_manager/window_manager.dart'; +import 'src/rust/frb_generated.dart'; +import 'src/theme.dart'; +import 'src/state/app_state.dart'; +import 'src/screens/setup_screen.dart'; +import 'src/screens/tasks_screen.dart'; +import 'src/screens/settings_screen.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await RustLib.init(); + + await windowManager.ensureInitialized(); + await windowManager.waitUntilReadyToShow( + const WindowOptions( + size: Size(400, 700), + minimumSize: Size(320, 500), + titleBarStyle: TitleBarStyle.hidden, + ), + () async { + await windowManager.setBackgroundColor(Colors.transparent); + await windowManager.setResizable(true); + await windowManager.show(); + await windowManager.focus(); + }, + ); + + runApp( + ChangeNotifierProvider( + create: (_) => AppState()..loadConfig(), + child: const BevyTasksApp(), + ), + ); +} + +class BevyTasksApp extends StatelessWidget { + const BevyTasksApp({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch(); + return MaterialApp( + title: 'Bevy Tasks', + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: state.darkMode ? ThemeMode.dark : ThemeMode.light, + home: const AppShell(), + ); + } +} + +class AppShell extends StatelessWidget { + const AppShell({super.key}); + + static const _edge = 8.0; + + SystemMouseCursor _cursorFor(ResizeEdge? edge) => switch (edge) { + ResizeEdge.top || ResizeEdge.bottom => SystemMouseCursors.resizeUpDown, + ResizeEdge.left || ResizeEdge.right => SystemMouseCursors.resizeLeftRight, + ResizeEdge.topLeft || ResizeEdge.bottomRight => SystemMouseCursors.resizeUpLeftDownRight, + ResizeEdge.topRight || ResizeEdge.bottomLeft => SystemMouseCursors.resizeUpRightDownLeft, + _ => SystemMouseCursors.basic, + }; + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final isDark = Theme.of(context).brightness == Brightness.dark; + return Scaffold( + backgroundColor: Colors.transparent, + body: LayoutBuilder(builder: (context, constraints) { + return MouseRegion( + cursor: SystemMouseCursors.basic, + hitTestBehavior: HitTestBehavior.translucent, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerHover: (event) { + // Update cursor based on edge proximity (handled by nested MouseRegion below) + }, + child: Stack( + children: [ + // Resize hit zones (in the 8px padding area) + ..._buildResizeZones(constraints), + // Main content with padding + Padding( + padding: const EdgeInsets.all(_edge), + child: Container( + decoration: BoxDecoration( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? Colors.white.withValues(alpha: 0.15) : Colors.black.withValues(alpha: 0.15), + ), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 2)), + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + if (state.screen == 'setup') + const SetupScreen() + else + const TasksScreen(), + if (state.error != null) + Positioned( + top: 0, left: 0, right: 0, + child: Material( + color: AppTheme.danger, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text(state.error!, style: const TextStyle(color: Colors.white, fontSize: 13)), + ), + GestureDetector( + onTap: state.clearError, + child: const Text('✕', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ), + if (state.screen == 'settings') + const SettingsScreen(), + ], + ), + ), + ), + ], + ), + ), + ); + }), + ); + } + + List _buildResizeZones(BoxConstraints constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + Widget zone(ResizeEdge edge, {required double left, required double top, required double width, required double height}) { + return Positioned( + left: left, top: top, width: width, height: height, + child: MouseRegion( + cursor: _cursorFor(edge), + child: GestureDetector( + onPanStart: (_) => windowManager.startResizing(edge), + ), + ), + ); + } + return [ + // Corners (larger hit area) + zone(ResizeEdge.topLeft, left: 0, top: 0, width: _edge * 2, height: _edge * 2), + zone(ResizeEdge.topRight, left: w - _edge * 2, top: 0, width: _edge * 2, height: _edge * 2), + zone(ResizeEdge.bottomLeft, left: 0, top: h - _edge * 2, width: _edge * 2, height: _edge * 2), + zone(ResizeEdge.bottomRight, left: w - _edge * 2, top: h - _edge * 2, width: _edge * 2, height: _edge * 2), + // Edges + zone(ResizeEdge.top, left: _edge * 2, top: 0, width: w - _edge * 4, height: _edge), + zone(ResizeEdge.bottom, left: _edge * 2, top: h - _edge, width: w - _edge * 4, height: _edge), + zone(ResizeEdge.left, left: 0, top: _edge * 2, width: _edge, height: h - _edge * 4), + zone(ResizeEdge.right, left: w - _edge, top: _edge * 2, width: _edge, height: h - _edge * 4), + ]; + } +} diff --git a/apps/flutter/lib/src/state/app_state.dart b/apps/flutter/lib/src/state/app_state.dart new file mode 100644 index 0000000..9c5ba65 --- /dev/null +++ b/apps/flutter/lib/src/state/app_state.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import '../rust/api.dart' as api; + +class AppState extends ChangeNotifier { + String screen = 'setup'; + api.AppConfigDto? config; + List lists = []; + String? activeListId; + List tasks = []; + bool darkMode = true; + bool syncing = false; + String? error; + + // Selected task for detail view + String? selectedTaskId; + + api.TaskListDto? get activeList => + activeListId == null ? null : lists.cast().firstWhere((l) => l?.id == activeListId, orElse: () => null); + + List get pendingTasks => tasks.where((t) => t.status == 'backlog').toList(); + List get completedTasks => tasks.where((t) => t.status == 'completed').toList(); + + bool get hasWorkspace => + config != null && config!.currentWorkspace != null && config!.workspaces.isNotEmpty; + + api.TaskDto? get selectedTask => + selectedTaskId == null ? null : tasks.cast().firstWhere((t) => t?.id == selectedTaskId, orElse: () => null); + + Future loadConfig() async { + try { + config = await api.getConfig(); + if (hasWorkspace) { + screen = 'tasks'; + await loadLists(); + } else { + screen = 'setup'; + } + } catch (e) { + config = const api.AppConfigDto(workspaces: [], currentWorkspace: null); + screen = 'setup'; + } + notifyListeners(); + } + + Future addWorkspace(String name, String path) async { + try { + await api.initWorkspace(path: path); + await api.addWorkspace(name: name, path: path); + config = await api.getConfig(); + await loadLists(); + screen = 'tasks'; + error = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future switchWorkspace(String name) async { + try { + await api.setCurrentWorkspace(name: name); + config = await api.getConfig(); + activeListId = null; + await loadLists(); + error = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future removeWorkspace(String name) async { + try { + await api.removeWorkspace(name: name); + config = await api.getConfig(); + if (!hasWorkspace) { + screen = 'setup'; + lists = []; + tasks = []; + activeListId = null; + } + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future loadLists() async { + try { + lists = await api.getLists(); + if (lists.isNotEmpty && activeListId == null) { + activeListId = lists[0].id; + } + if (activeListId != null) await loadTasks(); + } catch (e) { + error = e.toString(); + } + } + + Future loadTasks() async { + if (activeListId == null) return; + try { + tasks = await api.listTasks(listId: activeListId!); + } catch (e) { + error = e.toString(); + } + } + + Future selectList(String id) async { + activeListId = id; + selectedTaskId = null; + await loadTasks(); + notifyListeners(); + } + + Future createList(String name) async { + try { + final list = await api.createList(name: name); + lists = [...lists, list]; + activeListId = list.id; + tasks = []; + error = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future deleteList(String id) async { + try { + await api.deleteList(listId: id); + lists = lists.where((l) => l.id != id).toList(); + if (activeListId == id) { + activeListId = lists.isNotEmpty ? lists[0].id : null; + if (activeListId != null) { + await loadTasks(); + } else { + tasks = []; + } + } + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future createTask(String title, String description) async { + if (activeListId == null) return null; + try { + final task = await api.createTask(listId: activeListId!, title: title, description: description); + tasks = [...tasks, task]; + error = null; + notifyListeners(); + return task; + } catch (e) { + error = e.toString(); + notifyListeners(); + return null; + } + } + + Future toggleTask(String taskId) async { + if (activeListId == null) return; + try { + final updated = await api.toggleTask(listId: activeListId!, taskId: taskId); + if (updated.status == 'backlog') { + tasks = [updated, ...tasks.where((t) => t.id != taskId)]; + try { + await api.reorderTask(listId: activeListId!, taskId: taskId, newPosition: 0); + } catch (_) {} + } else { + tasks = tasks.map((t) => t.id == taskId ? updated : t).toList(); + } + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future updateTask(api.TaskDto task) async { + if (activeListId == null) return; + try { + await api.updateTask(listId: activeListId!, task: task); + tasks = tasks.map((t) => t.id == task.id ? task : t).toList(); + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future reorderTask(String taskId, int newPosition) async { + if (activeListId == null) return; + try { + await api.reorderTask(listId: activeListId!, taskId: taskId, newPosition: newPosition); + await loadTasks(); + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future deleteTask(String taskId) async { + if (activeListId == null) return; + try { + await api.deleteTask(listId: activeListId!, taskId: taskId); + tasks = tasks.where((t) => t.id != taskId).toList(); + if (selectedTaskId == taskId) selectedTaskId = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + void selectTask(String? taskId) { + selectedTaskId = taskId; + notifyListeners(); + } + + void toggleDarkMode() { + darkMode = !darkMode; + notifyListeners(); + } + + void setScreen(String s) { + screen = s; + notifyListeners(); + } + + void clearError() { + error = null; + notifyListeners(); + } +} diff --git a/apps/flutter/lib/src/theme.dart b/apps/flutter/lib/src/theme.dart new file mode 100644 index 0000000..eecd8d0 --- /dev/null +++ b/apps/flutter/lib/src/theme.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class AppTheme { + static const primary = Color(0xFF2D87B8); + static const primaryHover = Color(0xFF2474A0); + static const danger = Color(0xFFEF4444); + + static const surfaceLight = Color(0xFFFFFFFF); + static const cardLight = Color(0xFFF9FAFB); + static const textLight = Color(0xFF1F2937); + static const textSecondaryLight = Color(0xFF6B7280); + static const borderLight = Color(0xFFE5E7EB); + + static const surfaceDark = Color(0xFF242424); + static const cardDark = Color(0xFF303030); + static const textDark = Color(0xFFE5E7EB); + static const textSecondaryDark = Color(0xFF9CA3AF); + static const borderDark = Color(0xFF3D3D3D); + + static ThemeData light() => ThemeData( + brightness: Brightness.light, + colorScheme: const ColorScheme.light( + primary: primary, + surface: surfaceLight, + error: danger, + ), + scaffoldBackgroundColor: surfaceLight, + textTheme: GoogleFonts.notoSansTextTheme(ThemeData.light().textTheme), + dividerColor: borderLight, + cardColor: cardLight, + ); + + static ThemeData dark() => ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: primary, + surface: surfaceDark, + error: danger, + ), + scaffoldBackgroundColor: surfaceDark, + textTheme: GoogleFonts.notoSansTextTheme(ThemeData.dark().textTheme), + dividerColor: borderDark, + cardColor: cardDark, + ); +}