From fbd09f2b12d2e177db6b8fedf6d34770ae9e1e48 Mon Sep 17 00:00:00 2001 From: TechnicJelle <22576047+TechnicJelle@users.noreply.github.com> Date: Sun, 13 Oct 2024 04:13:30 +0200 Subject: [PATCH] Initial version of multi-project support!! Needs a looot more polish though... --- lib/hover.dart | 34 ++++ lib/main.dart | 3 +- ...h_picker_button.dart => project_tile.dart} | 59 ++++--- .../settings/new_project_dialog.dart | 85 ++++++++++ lib/main_menu/settings/projects_screen.dart | 100 ++++++++++-- lib/prefs.dart | 41 ++--- lib/project_view/close_project_button.dart | 4 +- lib/project_view/console.dart | 4 +- lib/project_view/control_row.dart | 3 +- lib/project_view/new_map_dialog.dart | 4 +- lib/project_view/sidebar/map_tile.dart | 145 +++++++++--------- lib/project_view/sidebar/sidebar.dart | 4 +- pubspec.lock | 24 +++ pubspec.yaml | 1 + 14 files changed, 360 insertions(+), 151 deletions(-) create mode 100644 lib/hover.dart rename lib/main_menu/{path_picker_button.dart => project_tile.dart} (81%) create mode 100644 lib/main_menu/settings/new_project_dialog.dart diff --git a/lib/hover.dart b/lib/hover.dart new file mode 100644 index 0000000..0f51950 --- /dev/null +++ b/lib/hover.dart @@ -0,0 +1,34 @@ +import "package:flutter/material.dart"; + +class Hover extends StatefulWidget { + final Widget alwaysChild; + final Widget hoverChild; + + const Hover({ + super.key, + required this.alwaysChild, + required this.hoverChild, + }); + + @override + State createState() => _HoverState(); +} + +class _HoverState extends State { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onHover: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: Stack( + children: [ + widget.alwaysChild, + if (_isHovering) widget.hoverChild, + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5c92e38..fec9c9a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "main_menu/main_menu.dart"; +import "main_menu/settings/projects_screen.dart"; import "prefs.dart"; import "project_view/close_project_button.dart"; import "project_view/project_view.dart"; @@ -46,7 +47,7 @@ class MyHomePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final Directory? projectDirectory = ref.watch(projectDirectoryProvider); + final Directory? projectDirectory = ref.watch(openProjectProvider); String title = "BlueMap GUI"; if (projectDirectory != null) { diff --git a/lib/main_menu/path_picker_button.dart b/lib/main_menu/project_tile.dart similarity index 81% rename from lib/main_menu/path_picker_button.dart rename to lib/main_menu/project_tile.dart index d5fd37f..66897e0 100644 --- a/lib/main_menu/path_picker_button.dart +++ b/lib/main_menu/project_tile.dart @@ -1,6 +1,5 @@ import "dart:io"; -import "package:file_picker/file_picker.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path/path.dart" as p; @@ -9,10 +8,11 @@ import "package:url_launcher/url_launcher_string.dart"; import "../main.dart"; import "../prefs.dart"; import "../utils.dart"; +import "settings/projects_screen.dart"; enum _PickingState { nothing, - picking, + directoryNotFound, scanning, downloading, downloadFailed, @@ -21,35 +21,40 @@ enum _PickingState { running, } -class PathPickerButton extends ConsumerStatefulWidget { - const PathPickerButton({super.key}); +class ProjectTile extends ConsumerStatefulWidget { + final Directory projectDirectory; + const ProjectTile(this.projectDirectory, {super.key}); @override - ConsumerState createState() => _PathPickerButtonState(); + ConsumerState createState() => _PathPickerButtonState(); } -class _PathPickerButtonState extends ConsumerState { +class _PathPickerButtonState extends ConsumerState { _PickingState _pickingState = _PickingState.nothing; String? errorText; + Directory get projectDirectory => widget.projectDirectory; + + @override + void initState() { + super.initState(); + + if (!projectDirectory.existsSync()) { + setState(() => _pickingState = _PickingState.directoryNotFound); + return; + } + } + @override Widget build(BuildContext context) { return switch (_pickingState) { - _PickingState.nothing => ElevatedButton( - onPressed: () async { - // == Picking Project Directory == - setState(() => _pickingState = _PickingState.picking); - final String? result = await FilePicker.platform.getDirectoryPath( - dialogTitle: "Select project folder", - ); - if (result == null) { - setState(() => _pickingState = _PickingState.nothing); - return; // User canceled the picker - } - + //TODO: The states other than ListTile should get more polish + _PickingState.nothing => ListTile( + title: Text(p.basename(projectDirectory.path)), + subtitle: Text(projectDirectory.path), + onTap: () async { // == Scanning for BlueMap CLI JAR == setState(() => _pickingState = _PickingState.scanning); - final Directory projectDirectory = Directory(result); final contents = projectDirectory.listSync(); NonHashedFile? susBlueMapJar; @@ -108,21 +113,11 @@ class _PathPickerButtonState extends ConsumerState { mapsDir.createSync(); //recreate maps dir (now empty) } - ref.read(projectDirectoryProvider.notifier).openProject(projectDirectory); + ref.read(openProjectProvider.notifier).openProject(projectDirectory); }, - child: const Text("Select project folder"), - ), - _PickingState.picking => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text("Waiting for user to pick a folder..."), - const SizedBox(height: 8), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: const LinearProgressIndicator(), - ), - ], ), + _PickingState.directoryNotFound => + Text("Directory ${projectDirectory.path} not found!"), _PickingState.scanning => Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/main_menu/settings/new_project_dialog.dart b/lib/main_menu/settings/new_project_dialog.dart new file mode 100644 index 0000000..8e75541 --- /dev/null +++ b/lib/main_menu/settings/new_project_dialog.dart @@ -0,0 +1,85 @@ +import "dart:io"; + +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:path/path.dart" as p; +import "package:path_provider/path_provider.dart"; + +import "../../prefs.dart"; + +//TODO: This thing needs a LOT of polish and error checking! +class NewProjectDialog extends ConsumerStatefulWidget { + const NewProjectDialog({super.key}); + + @override + NewProjectDialogState createState() => NewProjectDialogState(); +} + +class NewProjectDialogState extends ConsumerState { + final TextEditingController _nameController = TextEditingController(text: "untitled"); + TextEditingController? _locationController; + + String get _projectPath => + p.join(_locationController?.text ?? "", _nameController.text); + + @override + void initState() { + super.initState(); + getApplicationDocumentsDirectory().then((documentsDirectory) { + final String projectDir = p.join(documentsDirectory.path, "BlueMapGUI"); + setState(() { + _locationController = TextEditingController(text: projectDir); + }); + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("New project"), + content: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: "Name:", + ), + ), + const SizedBox(height: 16), + TextField( + controller: _locationController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: "Location:", + ), + ), + const SizedBox(height: 8), + Text( + "Project will be created in: $_projectPath", + style: + Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.grey), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () { + ref.read(knownProjectsProvider.notifier).addProject(Directory(_projectPath)); + Navigator.of(context).pop(); + }, + child: const Text("Create"), + ), + ], + ); + } +} diff --git a/lib/main_menu/settings/projects_screen.dart b/lib/main_menu/settings/projects_screen.dart index 31192bc..d578ebc 100644 --- a/lib/main_menu/settings/projects_screen.dart +++ b/lib/main_menu/settings/projects_screen.dart @@ -1,8 +1,33 @@ +import "dart:io"; + import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:url_launcher/url_launcher.dart"; +import "../../hover.dart"; import "../../prefs.dart"; -import "../path_picker_button.dart"; +import "../../project_view/project_view.dart"; +import "../project_tile.dart"; +import "new_project_dialog.dart"; + +class OpenProjectNotifier extends Notifier { + @override + Directory? build() { + return null; + } + + void openProject(Directory projectDirectory) { + state = projectDirectory; + } + + void closeProject() { + ref.read(openConfigProvider.notifier).close(); + state = null; + } +} + +final openProjectProvider = + NotifierProvider(() => OpenProjectNotifier()); class ProjectsScreen extends ConsumerWidget { const ProjectsScreen({super.key}); @@ -15,21 +40,64 @@ class ProjectsScreen extends ConsumerWidget { ); } - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Select an empty folder to store your BlueMap files in:"), - SizedBox(height: 8), - PathPickerButton(), - SizedBox(height: 8), - Text("The BlueMap CLI tool will be downloaded into that folder."), - SizedBox(height: 4), - Text("It will generate some default config files for you."), - SizedBox(height: 4), - Text("You will then need to configure your maps in the BlueMap GUI."), - ], - ), + final List projects = ref.watch(knownProjectsProvider); + + return Stack( + children: [ + ListView.builder( + itemCount: projects.length, + itemBuilder: (BuildContext context, int index) { + final Directory project = projects[index]; + return Hover( + alwaysChild: ProjectTile(project), + hoverChild: Positioned( + right: 16, + top: 12, + child: PopupMenuButton( + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.folder_open), + SizedBox(width: 8), + Text("Open in file manager"), + ], + ), + onTap: () => launchUrl(project.uri), + // does nothing when dir doesn't exist ↑ + ), + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.clear), + SizedBox(width: 8), + Text("Remove from projects"), + ], + ), + onTap: () { + ref.read(knownProjectsProvider.notifier).removeProject(project); + }, + ), + ], + ), + ), + ); + }, + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + showDialog( + context: context, + builder: (context) => const NewProjectDialog(), + ); + }, + ), + ), + ], ); } } diff --git a/lib/prefs.dart b/lib/prefs.dart index c78680e..a229065 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -10,7 +10,7 @@ Future initPrefs() async { cacheOptions: const SharedPreferencesWithCacheOptions( allowList: { JavaPathNotifier._javaPathKey, - ProjectDirectoryNotifier._projectPathKey, + KnownProjectsNotifier._knownProjectsKey, }, ), ); @@ -33,29 +33,34 @@ class JavaPathNotifier extends Notifier { final javaPathProvider = NotifierProvider(() => JavaPathNotifier()); -class ProjectDirectoryNotifier extends Notifier { - static const String _projectPathKey = "project_path"; +class KnownProjectsNotifier extends Notifier> { + static const String _knownProjectsKey = "known_projects"; @override - Directory? build() { - final String? bluemapJarPath = _prefs.getString(_projectPathKey); - if (bluemapJarPath == null) { - return null; - } else { - return Directory(bluemapJarPath); - } + List build() { + final List knownProjects = _prefs.getStringList(_knownProjectsKey) ?? []; + final List knownProjectsDirectories = + knownProjects.map((String path) => Directory(path)).toList(); + return knownProjectsDirectories; } - void openProject(Directory projectDirectory) { - state = projectDirectory; - _prefs.setString(_projectPathKey, projectDirectory.path); + void addProject(Directory projectDirectory) { + state = [...state, projectDirectory]; + projectDirectory.create(recursive: true); + _prefs.setStringList( + _knownProjectsKey, + state.map((Directory dir) => dir.path).toList(), + ); } - void closeProject() { - state = null; - _prefs.remove(_projectPathKey); + void removeProject(Directory projectDirectory) { + state = state.where((Directory dir) => dir != projectDirectory).toList(); + _prefs.setStringList( + _knownProjectsKey, + state.map((Directory dir) => dir.path).toList(), + ); } } -final projectDirectoryProvider = NotifierProvider( - () => ProjectDirectoryNotifier()); +final knownProjectsProvider = NotifierProvider>( + () => KnownProjectsNotifier()); diff --git a/lib/project_view/close_project_button.dart b/lib/project_view/close_project_button.dart index 3c5e892..5fb6e4d 100644 --- a/lib/project_view/close_project_button.dart +++ b/lib/project_view/close_project_button.dart @@ -2,7 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "../confirmation_dialog.dart"; -import "../prefs.dart"; +import "../main_menu/settings/projects_screen.dart"; class CloseProjectButton extends ConsumerWidget { const CloseProjectButton({super.key}); @@ -21,7 +21,7 @@ class CloseProjectButton extends ConsumerWidget { ], confirmAction: "Close", onConfirmed: () { - ref.read(projectDirectoryProvider.notifier).closeProject(); + ref.read(openProjectProvider.notifier).closeProject(); }, ); }, diff --git a/lib/project_view/console.dart b/lib/project_view/console.dart index ad97897..b7a3b12 100644 --- a/lib/project_view/console.dart +++ b/lib/project_view/console.dart @@ -2,7 +2,7 @@ import "package:animated_visibility/animated_visibility.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "../prefs.dart"; +import "../main_menu/settings/projects_screen.dart"; import "../utils.dart"; import "control_row.dart"; @@ -21,7 +21,7 @@ class OutputNotifier extends Notifier> { }); //clear output when project directory changes - ref.listen(projectDirectoryProvider, (previous, next) { + ref.listen(openProjectProvider, (previous, next) { state = []; }); diff --git a/lib/project_view/control_row.dart b/lib/project_view/control_row.dart index e39572a..b4bd720 100644 --- a/lib/project_view/control_row.dart +++ b/lib/project_view/control_row.dart @@ -12,13 +12,14 @@ import "package:rxdart/rxdart.dart"; import "package:url_launcher/url_launcher.dart"; import "../main.dart"; +import "../main_menu/settings/projects_screen.dart"; import "../prefs.dart"; import "../utils.dart"; final portExtractionRegex = RegExp(r"(?:port\s*|:)(\d{4,5})$"); final _processProvider = Provider((ref) { - final Directory? projectDirectory = ref.watch(projectDirectoryProvider); + final Directory? projectDirectory = ref.watch(openProjectProvider); if (projectDirectory == null) return null; final String? javaPath = ref.watch(javaPathProvider); if (javaPath == null) return null; diff --git a/lib/project_view/new_map_dialog.dart b/lib/project_view/new_map_dialog.dart index 73bc806..7bec34a 100644 --- a/lib/project_view/new_map_dialog.dart +++ b/lib/project_view/new_map_dialog.dart @@ -4,7 +4,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path/path.dart" as p; -import "../prefs.dart"; +import "../main_menu/settings/projects_screen.dart"; import "../utils.dart"; import "project_view.dart"; @@ -32,7 +32,7 @@ class _NewMapDialogState extends ConsumerState { @override void initState() { super.initState(); - final Directory? projDir = ref.read(projectDirectoryProvider); + final Directory? projDir = ref.read(openProjectProvider); if (projDir == null) return; projectDirectory = projDir; diff --git a/lib/project_view/sidebar/map_tile.dart b/lib/project_view/sidebar/map_tile.dart index 681c1e5..1b7ac56 100644 --- a/lib/project_view/sidebar/map_tile.dart +++ b/lib/project_view/sidebar/map_tile.dart @@ -5,8 +5,8 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path/path.dart" as p; import "../../confirmation_dialog.dart"; -import "../../delete_icon.dart"; -import "../../prefs.dart"; +import "../../hover.dart"; +import "../../main_menu/settings/projects_screen.dart"; import "../../utils.dart"; import "../project_view.dart"; @@ -22,89 +22,84 @@ class MapTile extends ConsumerStatefulWidget { class _MapTileState extends ConsumerState { File get configFile => widget.configFile; - bool _isHovering = false; - @override Widget build(BuildContext context) { final File? openConfig = ref.watch(openConfigProvider); - return GestureDetector( - onSecondaryTap: () {/*right click menu will come here*/}, - child: MouseRegion( - onEnter: (_) => setState(() => _isHovering = true), - onHover: (_) => setState(() => _isHovering = true), - onExit: (_) => setState(() => _isHovering = false), - child: Stack( - children: [ - ListTile( - title: Text(_toHuman(configFile)), + return Hover( + alwaysChild: ListTile( + title: Text(_toHuman(configFile)), + onTap: () { + ref.read(openConfigProvider.notifier).open(configFile); + }, + selected: openConfig != null && p.equals(openConfig.path, configFile.path), + ), + hoverChild: Positioned( + right: 16, + top: 12, + child: PopupMenuButton( + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.delete), + SizedBox(width: 8), + Text("Delete map"), + ], + ), onTap: () { - ref.read(openConfigProvider.notifier).open(configFile); - }, - selected: openConfig != null && p.equals(openConfig.path, configFile.path), - ), - if (_isHovering) - Positioned( - right: 6, - top: 4, - child: IconButton( - icon: const DeleteIcon(), - onPressed: () { - showConfirmationDialog( - context: context, - title: "Delete map", - content: [ - Wrap( - children: [ - const Text("Are you sure you want to delete the map \" "), - Text( - _toHuman(configFile), - style: pixelCode.copyWith(height: 1.4), - ), - const SizedBox(width: 1), - const Text("\" ?"), - ], - ), - const Text( - "This action cannot be undone!", - style: TextStyle(fontWeight: FontWeight.w500), - ), - const Text( - "However, you can just add the map again, as no unrecoverable data will be deleted.", - ), - const Text( - "Your Minecraft world data will not be affected by this action, only the BlueMap data.", + showConfirmationDialog( + context: context, + title: "Delete map", + content: [ + Wrap( + children: [ + const Text("Are you sure you want to delete the map \" "), + Text( + _toHuman(configFile), + style: pixelCode.copyWith(height: 1.4), ), + const SizedBox(width: 1), + const Text("\" ?"), ], - confirmAction: "Delete", - onConfirmed: () { - // == If the editor is open on that file, close it == - if (openConfig != null && - p.equals(openConfig.path, configFile.path)) { - ref.read(openConfigProvider.notifier).close(); - } + ), + const Text( + "This action cannot be undone!", + style: TextStyle(fontWeight: FontWeight.w500), + ), + const Text( + "However, you can just add the map again, as no unrecoverable data will be deleted.", + ), + const Text( + "Your Minecraft world data will not be affected by this action, only the BlueMap data.", + ), + ], + confirmAction: "Delete", + onConfirmed: () { + // == If the editor is open on that file, close it == + if (openConfig != null && + p.equals(openConfig.path, configFile.path)) { + ref.read(openConfigProvider.notifier).close(); + } - // == Delete the config file and the rendered map data == - final Directory? projectDirectory = - ref.watch(projectDirectoryProvider); - //delete the file next frame, to ensure the editor is closed - WidgetsBinding.instance.addPostFrameCallback((_) { - configFile.delete(); + // == Delete the config file and the rendered map data == + final Directory? projectDirectory = ref.watch(openProjectProvider); + //delete the file next frame, to ensure the editor is closed + WidgetsBinding.instance.addPostFrameCallback((_) { + configFile.delete(); - if (projectDirectory == null) return; - final String mapID = - p.basenameWithoutExtension(configFile.path); - final Directory mapDirectory = Directory( - p.join(projectDirectory.path, "web", "maps", mapID)); - if (mapDirectory.existsSync()) { - mapDirectory.delete(recursive: true); - } - }); - }, - ); + if (projectDirectory == null) return; + final String mapID = p.basenameWithoutExtension(configFile.path); + final Directory mapDirectory = + Directory(p.join(projectDirectory.path, "web", "maps", mapID)); + if (mapDirectory.existsSync()) { + mapDirectory.delete(recursive: true); + } + }); }, - ), - ), + ); + }, + ), ], ), ), diff --git a/lib/project_view/sidebar/sidebar.dart b/lib/project_view/sidebar/sidebar.dart index bc1dc88..a0a9c7f 100644 --- a/lib/project_view/sidebar/sidebar.dart +++ b/lib/project_view/sidebar/sidebar.dart @@ -5,7 +5,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path/path.dart" as p; -import "../../prefs.dart"; +import "../../main_menu/settings/projects_screen.dart"; import "../project_view.dart"; import "config_tile.dart"; import "map_tile.dart"; @@ -34,7 +34,7 @@ class _SidebarState extends ConsumerState { @override void initState() { super.initState(); - final Directory projectDirectory = ref.read(projectDirectoryProvider)!; + final Directory projectDirectory = ref.read(openProjectProvider)!; final String projectPath = projectDirectory.path; final Directory configDir = Directory(p.join(projectPath, "config")); for (final FileSystemEntity entity in configDir.listSync()) { diff --git a/pubspec.lock b/pubspec.lock index 4ec297a..a91a4ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -381,6 +381,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + url: "https://pub.dev" + source: hosted + version: "2.1.4" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a + url: "https://pub.dev" + source: hosted + version: "2.2.12" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" path_provider_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1667766..31a2ee2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: flutter_riverpod: ^2.5.1 meta: ^1.15.0 path: ^1.9.0 + path_provider: ^2.1.4 re_editor: ^0.4.0 re_highlight: ^0.0.3 rxdart: ^0.28.0