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