diff --git a/lib/main.dart b/lib/main.dart index 952342e..a26592d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dev_server_starter/src/app.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -5,3 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { runApp(const ProviderScope(child: DevServerStarterApp())); } + +String? get userHome => + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; diff --git a/lib/src/app.dart b/lib/src/app.dart index 5a3bd04..2e4ea3f 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,5 +1,5 @@ -import 'package:dev_server_starter/src/screens/console.dart'; import 'package:dev_server_starter/src/screens/login.dart'; +import 'package:dev_server_starter/src/screens/userinfo.dart'; import 'package:flutter/material.dart'; import 'screens/home.dart'; @@ -13,16 +13,17 @@ class DevServerStarterApp extends StatelessWidget { routes: { "/": (context) => const LoginScreenWidget(), "/home": (context) => const HomeScreen(), + "/settings": (context) => const SettingsScreen(), }, title: 'Dev Server Starter', theme: ThemeData( useMaterial3: true, - colorSchemeSeed: Colors.amber[400], + colorSchemeSeed: const Color(0xFFFFFF55), ), darkTheme: ThemeData( brightness: Brightness.dark, useMaterial3: true, - colorSchemeSeed: Colors.amber[400], + colorSchemeSeed: const Color(0xFFFFFF55), ), debugShowCheckedModeBanner: false, ); diff --git a/lib/src/provider/events.dart b/lib/src/provider/events.dart new file mode 100644 index 0000000..63c58bb --- /dev/null +++ b/lib/src/provider/events.dart @@ -0,0 +1,303 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:dev_server_starter/src/provider/user.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mysql_client/mysql_client.dart'; + +final portProvider = Provider((ref) { + return 49507; +}); + +final portForwardProvider = FutureProvider((ref) async { + final client = await ref.watch(sshClientProvider.future); + final port = ref.watch(portProvider); + final serverSocket = await ServerSocket.bind("127.0.0.1", port); + serverSocket.listen((socket) async { + final forward = await client.forwardLocal('127.0.0.1', 3306); + forward.stream.cast>().pipe(socket); + socket.pipe(forward.sink); + }); + ref.onDispose(() async { + try { + await serverSocket.close(); + } catch (e) { + print(e); + } + }); + return serverSocket.port; +}); + +final mysqlClientProvider = FutureProvider((ref) async { + final port = await ref.watch(portForwardProvider.future); + final userData = await ref.watch(userDataProvider.future); + final conn = await MySQLConnection.createConnection( + host: "localhost", + port: port, + userName: userData.sqlUserName!, + password: userData.sqlPassword!, + secure: false, + databaseName: userData.useDevDB ? "developer" : "core", + ); + await conn.connect(timeoutMs: 200).catchError((err) { + ref.invalidate(portForwardProvider); + }); + ref.onDispose(() { + if (conn.connected) conn.close(); + }); + return conn; +}, dependencies: [portForwardProvider, userDataProvider]); + +final eventRepositoryProvider = FutureProvider((ref) async { + return EventRepository(await ref.watch(mysqlClientProvider.future)); +}, dependencies: [mysqlClientProvider]); + +final eventsListProvider = FutureProvider>((ref) async { + final repo = await ref.watch(eventRepositoryProvider.future); + return repo.listEvents(); +}, dependencies: [eventRepositoryProvider]); + +class EventRepository { + final MySQLConnection _connection; + + final _statements = >{}; + + EventRepository(this._connection); + + Future getStatement(String sql) async { + return _statements.putIfAbsent( + sql.hashCode, () => _connection.prepare(sql)); + } + + Future> listEvents() async { + final result = await _connection.execute( + "SELECT EventName, EventID, Start, End FROM Event ORDER BY Start DESC"); + return result.rows + .map((e) => ShortEvent( + e.colByName("EventName")!, + e.typedColByName("EventID")!, + DateTime.parse(e.colByName("Start")!), + DateTime.parse(e.colByName("End")!))) + .toList(); + } + + Future getEvent(int id) async { + final result = await getStatement("SELECT * FROM Event WHERE EventID = ?") + .then((value) => value.execute([id])); + return result.rows.map((e) => eventFromResult(e)).first; + } + + Future> getTeams(int eventId) async { + final result = await getStatement( + "SELECT Team.TeamID, TeamName, TeamColor FROM TeamTeilnahme JOIN Team ON TeamTeilnahme.TeamID = Team.TeamID WHERE EventID = ?") + .then((value) => value.execute([eventId])); + return result.rows + .map((e) => ShortTeam(e.typedColByName("TeamID")!, + e.colByName("TeamName")!, e.colByName("TeamColor")!)) + .toList(); + } + + Future updateEvent( + int id, + String name, + DateTime deadline, + DateTime start, + DateTime end, + int maxTeamMembers, + String? schemType, + bool publicOnly, + bool useSpectateSystem) async { + await getStatement("" + "UPDATE `Event` SET `EventName`=?,`Deadline`=?,`Start`=?,`End`=?,`MaximumTeamMembers`=?,`SchemType`=?,`PublicSchemsOnly`=?,`SpectateSystem`=? WHERE `EventID` = ?") + .then((value) => value.execute([ + name, + deadline.toIso8601String(), + start.toIso8601String(), + end.toIso8601String(), + maxTeamMembers, + schemType, + publicOnly ? 1 : 0, + useSpectateSystem ? 1 : 0, + id + ])); + } + + Event eventFromResult(ResultSetRow e) { + return Event( + id: e.typedColByName("EventID")!, + name: e.colByName("EventName")!, + deadline: DateTime.parse(e.colByName("Deadline")!), + start: DateTime.parse(e.colByName("Start")!), + end: DateTime.parse(e.colByName("End")!), + maxTeamMembers: e.typedColByName("MaximumTeamMembers")!, + schemType: e.colByName("SchemType"), + publicOnly: e.typedColByName("PublicSchemsOnly")!, + useSpectateSystem: e.typedColByName("SpectateSystem")!, + ); + } + + Future createEvent(String name, DateTime start, DateTime end) async { + await getStatement( + "INSERT INTO `Event` (`EventName`, `Start`, `End`, `MaximumTeamMembers`, `PublicSchemsOnly`) VALUES (?, ?, ?, 5, 0)") + .then((value) => value + .execute([name, start.toIso8601String(), end.toIso8601String()])); + final result = await getStatement("SELECT * FROM Event WHERE EventName = ?") + .then((value) => value.execute([name])); + return result.rows.map((e) => eventFromResult(e)).first; + } + + Future deleteEvent(int id) async { + await getStatement("DELETE FROM Event WHERE EventID = ?") + .then((value) => value.execute([id])); + } + + Future> getFightOfEvent(int id) async { + final result = await getStatement( + "SELECT FightID, StartTime, Spielmodus, Map, tb.TeamID as BlueTeamID, tb.TeamColor as BlueTeamColor, tb.Teamname as BlueTeamName, tr.TeamID as RedTeamID, tr.TeamColor as RedTeamColor, tr.Teamname as RedTeamName, Kampfleiter, Ergebnis FROM EventFight JOIN Team tb ON tb.TeamID = EventFight.TeamBlue JOIN Team tr ON tr.TeamID = EventFight.TeamRed WHERE EventID = ?") + .then((value) => value.execute([id])); + return result.rows.map((e) => EventFight.fromResult(e)).toList(); + } + + Future deleteFight(int id) async { + await getStatement("DELETE FROM EventFight WHERE FightID = ?") + .then((value) => value.execute([id])); + } + + Future createFight(int eventId, DateTime start, String mode, String map, + ShortTeam blue, ShortTeam red) { + return getStatement( + "INSERT INTO `EventFight` (`EventID`, `StartTime`, `Spielmodus`, `Map`, `TeamBlue`, `TeamRed`, `Kampfleiter`) VALUES (?, ?, ?, ?, ?, ?, 0)") + .then((value) => value.execute( + [eventId, start.toIso8601String(), mode, map, blue.id, red.id])); + } + + Future updateFight( + int id, DateTime start, String mode, String map, int referee) { + return getStatement( + "UPDATE `EventFight` SET `StartTime`=?,`Spielmodus`=?,`Map`=?,`Kampfleiter`=? WHERE `FightID` = ?") + .then((value) => + value.execute([start.toIso8601String(), mode, map, referee, id])); + } +} + +class EventFight { + final int id; + final String gameMode; + final String map; + final DateTime start; + final ShortTeam redTeam; + final ShortTeam blueTeam; + final int fightLeaderId; + final int score; + + EventFight(this.id, this.gameMode, this.map, this.start, this.redTeam, + this.blueTeam, this.fightLeaderId, this.score); + + ShortTeam get winner { + switch (score) { + case 1: + return blueTeam; + case 2: + return redTeam; + case 3: + return ShortTeam(-1, "Tie", "7"); + default: + return ShortTeam(-1, "Unknown", "7"); + } + } + + factory EventFight.fromResult(ResultSetRow e) { + return EventFight( + e.typedColByName("FightID")!, + e.colByName("Spielmodus")!, + e.colByName("Map")!, + DateTime.parse(e.colByName("StartTime")!), + ShortTeam(e.typedColByName("RedTeamID")!, + e.colByName("RedTeamName")!, e.colByName("RedTeamColor")!), + ShortTeam(e.typedColByName("BlueTeamID")!, + e.colByName("BlueTeamName")!, e.colByName("BlueTeamColor")!), + e.typedColByName("Kampfleiter")!, + e.typedColByName("Ergebnis")!, + ); + } +} + +class ShortTeam { + final int id; + final String name; + final String colorCode; + + ShortTeam(this.id, this.name, this.colorCode); + + Color get color { + switch (colorCode) { + case "1": + return const Color(0xFF0000AA); + case "2": + return const Color(0xFF00AA00); + case "3": + return const Color(0xFF00AAAA); + case "4": + return const Color(0xFFAA0000); + case "5": + return const Color(0xFFAA00AA); + case "6": + return const Color(0xFFFFAA00); + case "7": + return const Color(0xFFAAAAAA); + case "8": + return const Color(0xFF555555); + case "9": + return const Color(0xFF5555FF); + case "a": + return const Color(0xFF55FF55); + case "b": + return const Color(0xFF55FFFF); + case "c": + return const Color(0xFFFF5555); + case "d": + return const Color(0xFFFF55FF); + case "e": + return const Color(0xFFFFFF55); + case "f": + return const Color(0xFFFFFFFF); + default: + return const Color(0xFF000000); + } + } +} + +class Event { + final int id; + final String name; + final DateTime deadline; + final DateTime start; + final DateTime end; + final int maxTeamMembers; + final String? schemType; + final bool publicOnly; + final bool useSpectateSystem; + + Event({ + required this.id, + required this.name, + required this.deadline, + required this.start, + required this.end, + required this.maxTeamMembers, + required this.schemType, + required this.publicOnly, + required this.useSpectateSystem, + }); +} + +class ShortEvent { + final String name; + final int id; + final DateTime start; + final DateTime end; + + ShortEvent(this.name, this.id, this.start, this.end); + + get isUpcoming => start.isAfter(DateTime.now()); +} diff --git a/lib/src/provider/server.dart b/lib/src/provider/server.dart index 60d1b88..4af64cc 100644 --- a/lib/src/provider/server.dart +++ b/lib/src/provider/server.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; + import 'package:dartssh2/dartssh2.dart'; +import 'package:dev_server_starter/src/screens/console.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'user.dart'; @@ -19,3 +23,9 @@ final sftpProvider = FutureProvider((ref) async { ref.onDispose(sftp.close); return sftp; }); + +final favoriteServersProvider = FutureProvider((ref) async { + final prefs = await SharedPreferences.getInstance(); + final ret = prefs.getStringList("favorite_servers") ?? []; + return ret.map((e) => ServerStartParameters.fromJson(jsonDecode(e))).toList(); +}); diff --git a/lib/src/provider/types.dart b/lib/src/provider/types.dart new file mode 100644 index 0000000..f7ecd9c --- /dev/null +++ b/lib/src/provider/types.dart @@ -0,0 +1,61 @@ +import 'package:dev_server_starter/src/provider/events.dart'; +import 'package:dev_server_starter/src/provider/server.dart'; +import 'package:dev_server_starter/src/provider/user.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:yaml/yaml.dart'; + +final fightServersProvider = FutureProvider((ref) async { + final client = await ref.watch(sftpProvider.future); + return await client.listdir("/configs/GameModes").then( + (value) => value + .map((e) => e.filename) + .where((element) => element != "." && element != "..") + .where((element) => + element.endsWith(".yml") && !element.endsWith(".kits.yml")) + .map((e) => e.substring(0, e.length - 4)) + .toList(), + ); +}); + +final schematicTypesProvider = FutureProvider((ref) async { + final client = await ref.watch(sshClientProvider.future); + final result = await client + .run("grep ' Type: ' /configs/GameModes/*.yml") + .then((value) => String.fromCharCodes(value)); + return result + .split("\n") + .where((element) => element.isNotEmpty) + .map((e) => e.split(": ")[2]) + .toList(); +}); + +final mapsProvider = FutureProvider>>((ref) async { + final client = await ref.watch(sshClientProvider.future); + final gamemodes = await ref.read(fightServersProvider.future); + final maps = >{}; + for (var gm in gamemodes) { + final result = loadYaml(await client + .run("cat /configs/GameModes/$gm.yml") + .then((value) => String.fromCharCodes(value))); + final gmMaps = result?["Server"]?["Maps"] as YamlList?; + if (gmMaps != null) { + maps[gm] = gmMaps.toList().map((e) => e.toString()).toList(); + } + } + return maps; +}); + +final usersProvider = FutureProvider((ref) async { + final client = await ref.watch(mysqlClientProvider.future); + final result = await client.execute("SELECT id, UserName FROM UserData"); + return result.rows + .map((e) => User(e.typedColByName("id")!, e.colByName("UserName")!)) + .toList(); +}); + +class User { + final int id; + final String name; + + User(this.id, this.name); +} diff --git a/lib/src/provider/user.dart b/lib/src/provider/user.dart index dec3ac7..44bb20a 100644 --- a/lib/src/provider/user.dart +++ b/lib/src/provider/user.dart @@ -26,7 +26,7 @@ final clientFunctionProvider = default: throw Exception("Invalid method"); } -}); +}, dependencies: [userDataProvider]); final userDataProvider = FutureProvider((ref) async { final prefs = await SharedPreferences.getInstance(); @@ -34,11 +34,20 @@ final userDataProvider = FutureProvider((ref) async { final method = prefs.getInt("method")!; final password = prefs.getString("password"); final privateKey = prefs.getString("privateKeyFile"); + final sqlUsername = prefs.getString("sqlUsername"); + final sqlPassword = prefs.getString("sqlPassword"); + final useDevDB = prefs.getBool("sqlUseDevDB") ?? false; + final uneditablePastEvents = prefs.getBool("uneditablePastEvents") ?? false; + return UserData( username: username, method: method, password: password, privateKeyFile: privateKey, + sqlPassword: sqlPassword, + sqlUserName: sqlUsername, + useDevDB: useDevDB, + uneditablePastEvents: uneditablePastEvents, ); }); @@ -48,15 +57,26 @@ class UserData { final String? privateKeyFile; final int method; + final String? sqlUserName; + final String? sqlPassword; + final bool useDevDB; + final bool uneditablePastEvents; + UserData({ required this.username, - this.password, - this.privateKeyFile, + required this.password, + required this.privateKeyFile, required this.method, + required this.sqlUserName, + required this.sqlPassword, + required this.useDevDB, + required this.uneditablePastEvents, }); } final sshClientProvider = FutureProvider((ref) async { final clientFunction = await ref.watch(clientFunctionProvider.future); - return clientFunction!(await SSHSocket.connect("steamwar.de", 22)); -}); + final client = clientFunction(await SSHSocket.connect("steamwar.de", 22)); + await client.authenticated; + return client; +}, dependencies: [clientFunctionProvider]); diff --git a/lib/src/screens/components/error.dart b/lib/src/screens/components/error.dart new file mode 100644 index 0000000..28abc33 --- /dev/null +++ b/lib/src/screens/components/error.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class ErrorComponent extends StatelessWidget { + final Object error; + final StackTrace? stackTrace; + const ErrorComponent(this.error, this.stackTrace, {Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.red, + child: Center( + child: Column( + children: [ + SelectableText(error.toString()), + if (stackTrace != null) Text(stackTrace.toString()), + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/components/event_dialog.dart b/lib/src/screens/components/event_dialog.dart new file mode 100644 index 0000000..b53252e --- /dev/null +++ b/lib/src/screens/components/event_dialog.dart @@ -0,0 +1,85 @@ +import 'package:dev_server_starter/src/provider/events.dart'; +import 'package:dev_server_starter/src/screens/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class EventDialog extends HookConsumerWidget { + const EventDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final events = ref.watch(eventsListProvider); + final eventName = useTextEditingController(); + final eventExists = useState(false); + + final startTime = useState(DateTime.now()); + final endTime = useState(DateTime.now().add(const Duration(hours: 1))); + + return AlertDialog( + title: const Text('Create Event'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: InputDecoration( + labelText: "Event Name", + errorText: eventExists.value ? "Event already exists" : null, + ), + controller: eventName, + onChanged: (value) { + if (events.hasValue) { + eventExists.value = + events.value!.any((element) => element.name == value); + } + }, + ), + Row( + children: [ + const Text("Start: "), + DateTimeEditor((p0) { + startTime.value = p0; + }, startTime.value, true), + ], + ), + Row( + children: [ + const Text("End: "), + DateTimeEditor((p0) { + endTime.value = p0; + }, endTime.value, true), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text( + "Cancel", + style: TextStyle(color: Colors.red), + ), + ), + TextButton( + onPressed: () async { + final nav = Navigator.of(context); + final event = await ref.read(eventRepositoryProvider.future).then( + (value) => value.createEvent( + eventName.text, startTime.value, endTime.value)); + nav.pop(); + nav.push( + MaterialPageRoute( + builder: (context) { + return EditEventScreen(true, event); + }, + ), + ); + }, + child: const Text("Create"), + ), + ], + ); + } +} diff --git a/lib/src/screens/components/events_list.dart b/lib/src/screens/components/events_list.dart new file mode 100644 index 0000000..45de4c0 --- /dev/null +++ b/lib/src/screens/components/events_list.dart @@ -0,0 +1,108 @@ +import 'package:dev_server_starter/src/provider/events.dart'; +import 'package:dev_server_starter/src/provider/user.dart'; +import 'package:dev_server_starter/src/screens/components/error.dart'; +import 'package:dev_server_starter/src/screens/event.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'event_dialog.dart'; + +class EventListComponent extends HookConsumerWidget { + const EventListComponent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final events = ref.watch(eventsListProvider); + final userData = ref.watch(userDataProvider); + return events.when(data: (data) { + final upcomingEvents = data.where((element) => element.isUpcoming); + final pastEvents = data.where((element) => !element.isUpcoming); + return ListView( + children: [ + FloatingActionButton.extended( + onPressed: () { + showDialog(context: context, builder: (context) => EventDialog()); + }, + label: const Text("Create Event"), + icon: const Icon(Icons.add), + ), + const SizedBox( + height: 16, + ), + if (upcomingEvents.isNotEmpty) + Text( + "Upcoming Events", + style: Theme.of(context).textTheme.headline6!, + ), + ...upcomingEvents.map( + (e) => ListTile( + title: Text(e.name), + subtitle: Text("${e.start} - ${e.end}"), + leading: const Icon(Icons.event), + onTap: () async { + final nav = Navigator.of(context); + final event = await ref + .read(eventRepositoryProvider.future) + .then((value) => value.getEvent(e.id)); + nav.push( + MaterialPageRoute( + builder: (context) { + return EditEventScreen(true, event); + }, + ), + ); + }, + ), + ), + if (pastEvents.isNotEmpty) + Text( + "Past Events", + style: Theme.of(context).textTheme.headline6!, + ), + ...pastEvents.map((e) => ListTile( + title: Text(e.name), + leading: const Icon(Icons.check), + subtitle: Text("${e.start} - ${e.end}"), + onTap: () async { + final nav = Navigator.of(context); + final event = await ref + .read(eventRepositoryProvider.future) + .then((value) => value.getEvent(e.id)); + nav.push( + MaterialPageRoute( + builder: (context) { + return EditEventScreen( + false || + (userData.valueOrNull?.uneditablePastEvents ?? + false), + event); + }, + ), + ); + }, + )), + ], + ); + }, error: (err, stack) { + final userdata = ref.read(userDataProvider); + if (userdata.value?.sqlUserName == null || + userdata.value?.sqlPassword == null) { + return const Center( + child: Text("Please set your SQL credentials in the settings"), + ); + } + if (err.toString() == + "MySQLClientException: Can not execute query: connection closed") { + ref.invalidate(mysqlClientProvider); + return const Center( + child: CircularProgressIndicator(), + ); + } + return ErrorComponent(err, stack); + }, loading: () { + return const Center( + child: CircularProgressIndicator(), + ); + }); + } +} diff --git a/lib/src/screens/components/server_list.dart b/lib/src/screens/components/server_list.dart new file mode 100644 index 0000000..41f77a6 --- /dev/null +++ b/lib/src/screens/components/server_list.dart @@ -0,0 +1,290 @@ +import 'dart:convert'; + +import 'package:dev_server_starter/src/screens/components/error.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../provider/server.dart'; +import '../console.dart'; + +class ServerListComponent extends HookConsumerWidget { + const ServerListComponent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final servers = ref.watch(serversProvider); + final favoriteServers = ref.watch(favoriteServersProvider); + return servers.when( + data: (data) { + return ListView( + children: [ + favoriteServers.when( + data: (data) { + if (data.isEmpty) { + return Container(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text("Favorites"), + ), + SizedBox( + height: 200, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: [ + for (final server in data) + AspectRatio( + aspectRatio: 1, + child: Card( + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return ConsoleScreen( + server, + ); + }, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(server.name, + style: Theme.of(context) + .textTheme + .headline6), + if (server.world != null) + Text("World: ${server.world!}"), + if (server.plugins != null) + Text("Plugins: ${server.plugins!}"), + if (server.port != null) + Text("Port: ${server.port!}"), + const Spacer(), + Row( + mainAxisAlignment: + MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () async { + final remove = + await showDialog( + context: context, + builder: (contex) { + return AlertDialog( + title: const Text( + "Remove from favorites?"), + actions: [ + TextButton( + onPressed: + () { + Navigator.of( + context) + .pop( + false); + }, + child: const Text( + "Cancel")), + TextButton( + onPressed: + () async { + Navigator.of( + context) + .pop( + true); + }, + child: const Text( + "Remove")), + ], + ); + }); + if (remove) { + final favs = data.toList(); + favs.remove(server); + final prefs = + await SharedPreferences + .getInstance(); + prefs.setStringList( + "favorite_servers", + favs + .map((e) => + jsonEncode( + e.toJson())) + .toList()); + ref.invalidate( + favoriteServersProvider); + } + }, + icon: const Icon(Icons.delete), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ], + ); + }, + error: (err, stack) => ErrorComponent(err, stack), + loading: () => const LinearProgressIndicator()), + for (final server in data) + ListTile( + leading: const Icon(Icons.dns), + title: Text(server), + onTap: () { + showDialog( + context: context, + builder: (context) => + _CustomizeServerStartParameters(server)); + }, + trailing: Tooltip( + message: "Quick Start", + child: IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ConsoleScreen(ServerStartParameters(server)), + ), + ); + }, + ), + ), + ), + ], + ); + }, + error: (err, stack) { + return ErrorComponent(err, stack); + }, + loading: () { + return const Center( + child: CircularProgressIndicator(), + ); + }, + ); + } +} + +class _CustomizeServerStartParameters extends HookConsumerWidget { + final String server; + const _CustomizeServerStartParameters(this.server, {Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final worldNameController = useTextEditingController(); + final pluginsController = useTextEditingController(); + final portController = useTextEditingController(); + final saved = useState(false); + + ServerStartParameters constructParameters() { + return ServerStartParameters( + server, + world: + worldNameController.text.isEmpty ? null : worldNameController.text, + plugins: pluginsController.text.isEmpty ? null : pluginsController.text, + port: + portController.text.isEmpty ? null : int.parse(portController.text), + ); + } + + return AlertDialog( + title: Text("Start $server"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: const InputDecoration( + labelText: "World Name", + border: OutlineInputBorder(), + ), + controller: worldNameController, + onChanged: (d) => saved.value = false, + ), + const SizedBox( + height: 16, + ), + TextField( + decoration: const InputDecoration( + labelText: "Plugins", + border: OutlineInputBorder(), + ), + controller: pluginsController, + onChanged: (d) => saved.value = false, + ), + const SizedBox( + height: 16, + ), + // Allow only Numbers + TextField( + decoration: const InputDecoration( + labelText: "Port", + border: OutlineInputBorder(), + ), + inputFormatters: [ + FilteringTextInputFormatter.singleLineFormatter, + FilteringTextInputFormatter.digitsOnly + ], + keyboardType: TextInputType.number, + controller: portController, + onChanged: (d) => saved.value = false, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () async { + final favs = await ref.read(favoriteServersProvider.future); + favs.add(constructParameters()); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + "favorite_servers", + favs + .map((e) => e.toJson()) + .map((e) => jsonEncode(e)) + .toList()); + ref.invalidate(favoriteServersProvider); + saved.value = true; + }, + child: Text(saved.value ? "Saved!" : "Save")), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ConsoleScreen(constructParameters()), + ), + ); + }, + child: const Text("Start"), + ), + ], + ); + } +} diff --git a/lib/src/screens/console.dart b/lib/src/screens/console.dart index 8651241..947e83d 100644 --- a/lib/src/screens/console.dart +++ b/lib/src/screens/console.dart @@ -5,8 +5,11 @@ import 'package:dev_server_starter/src/provider/user.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:xterm/xterm.dart'; +part 'console.g.dart'; + class ConsoleScreen extends StatefulHookConsumerWidget { final ServerStartParameters parameters; const ConsoleScreen(this.parameters, {Key? key}) : super(key: key); @@ -90,7 +93,8 @@ class _ConsoleScreenState extends ConsumerState { final clientFut = ref.read(sshClientProvider); final client = clientFut.value!; client - .execute("python3 /binarys/dev.py ${widget.parameters.name} -w 3") + .execute( + "python3 /binarys/dev.py ${widget.parameters.name} ${widget.parameters.extraArguments}") .then((value) { session = value; session.stdout.listen((event) { @@ -111,6 +115,7 @@ class _ConsoleScreenState extends ConsumerState { } } +@JsonSerializable() class ServerStartParameters { final String name; final String? plugins; @@ -118,4 +123,26 @@ class ServerStartParameters { final int? port; ServerStartParameters(this.name, {this.plugins, this.world, this.port}); + + String get extraArguments { + final args = []; + if (plugins != null) { + args.add("-p"); + args.add(plugins!); + } + if (world != null) { + args.add("-w"); + args.add(world!); + } + if (port != null) { + args.add("--port"); + args.add(port.toString()); + } + return args.join(" "); + } + + factory ServerStartParameters.fromJson(Map json) => + _$ServerStartParametersFromJson(json); + + Map toJson() => _$ServerStartParametersToJson(this); } diff --git a/lib/src/screens/console.g.dart b/lib/src/screens/console.g.dart new file mode 100644 index 0000000..ff6e5ce --- /dev/null +++ b/lib/src/screens/console.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'console.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ServerStartParameters _$ServerStartParametersFromJson( + Map json) => + ServerStartParameters( + json['name'] as String, + plugins: json['plugins'] as String?, + world: json['world'] as String?, + port: json['port'] as int?, + ); + +Map _$ServerStartParametersToJson( + ServerStartParameters instance) => + { + 'name': instance.name, + 'plugins': instance.plugins, + 'world': instance.world, + 'port': instance.port, + }; diff --git a/lib/src/screens/event.dart b/lib/src/screens/event.dart new file mode 100644 index 0000000..7d86cd8 --- /dev/null +++ b/lib/src/screens/event.dart @@ -0,0 +1,657 @@ +import 'dart:math'; + +import 'package:dev_server_starter/src/provider/events.dart'; +import 'package:dev_server_starter/src/provider/types.dart'; +import 'package:dev_server_starter/src/screens/components/error.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'search_delegates.dart'; + +class EditEventScreen extends HookConsumerWidget { + final bool editable; + final Event event; + const EditEventScreen(this.editable, this.event, {Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nameController = useTextEditingController(text: event.name); + final deadlineState = useState(event.deadline); + final startDateState = useState(event.start); + final endDateState = useState(event.end); + final maxTeamMembersState = useState(event.maxTeamMembers); + final maxTeamMembersController = + useTextEditingController(text: event.maxTeamMembers.toString()); + final invalidMaxTeamMembers = useState(false); + final schematicTypeState = useState(event.schemType); + final publicOnlyState = useState(event.publicOnly); + final spectateSystemState = useState(event.useSpectateSystem); + + final changed = useState(false); + + final teams = useMemoized(() { + return ref + .read(eventRepositoryProvider.future) + .then((value) => value.getTeams(event.id)); + }); + + return Scaffold( + appBar: AppBar( + title: Text("${editable ? "Edit" : "View"} ${event.name}"), + actions: [ + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Delete Event"), + content: const Text( + "Are you sure you want to delete this event?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel")), + TextButton( + onPressed: () { + ref.read(eventRepositoryProvider.future).then( + (value) => value.deleteEvent(event.id)); + ref.invalidate(eventsListProvider); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + child: const Text( + "Delete", + style: TextStyle(color: Colors.red), + )), + ], + )); + }, + icon: const Icon(Icons.delete_outline, color: Colors.red), + ), + IconButton( + onPressed: changed.value + ? () { + ref + .read(eventRepositoryProvider.future) + .then((value) => value.updateEvent( + event.id, + nameController.text, + deadlineState.value, + startDateState.value, + endDateState.value, + maxTeamMembersState.value, + schematicTypeState.value, + publicOnlyState.value, + spectateSystemState.value, + )) + .whenComplete( + () => ref.invalidate(eventsListProvider)); + Navigator.of(context).pop(); + } + : null, + icon: const Icon(Icons.save)), + ], + leading: IconButton( + onPressed: () async { + var canPop = false; + if (changed.value) { + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Unsaved changes"), + content: const Text( + "You have unsaved changes. Do you want to discard them?"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("No")), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Yes", + style: TextStyle(color: Colors.red))), + ], + )); + if (accepted != null && accepted) { + canPop = true; + } + } else { + canPop = true; + } + if (canPop) Navigator.of(context).pop(); + }, + icon: const Icon(Icons.arrow_back), + ), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: "Name", + border: OutlineInputBorder(), + ), + enabled: editable, + onChanged: (value) { + changed.value = true; + }, + ), + const SizedBox(height: 8), + Row( + children: [ + const Text("Deadline: "), + DateTimeEditor((p0) { + deadlineState.value = p0; + changed.value = true; + }, deadlineState.value, editable), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Text("Start: "), + DateTimeEditor((p0) { + startDateState.value = p0; + changed.value = true; + }, startDateState.value, editable), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Text("End: "), + DateTimeEditor((p0) { + endDateState.value = p0; + changed.value = true; + }, endDateState.value, editable), + ], + ), + const SizedBox(height: 8), + TextField( + controller: maxTeamMembersController, + decoration: InputDecoration( + labelText: "Max Team Members", + border: const OutlineInputBorder(), + errorText: invalidMaxTeamMembers.value + ? "Must be a number above 0" + : null), + enabled: editable, + keyboardType: TextInputType.number, + onChanged: (value) { + if (value.isEmpty || + int.tryParse(value) == null || + int.parse(value) <= 0) { + invalidMaxTeamMembers.value = true; + } else { + invalidMaxTeamMembers.value = false; + maxTeamMembersState.value = int.parse(value); + changed.value = true; + } + }, + ), + if (editable) + Slider( + value: maxTeamMembersState.value.toDouble(), + onChanged: editable + ? (p0) { + maxTeamMembersState.value = p0.toInt(); + maxTeamMembersController.text = p0.toInt().toString(); + changed.value = true; + } + : null, + min: min(1, maxTeamMembersState.value.toDouble()), + max: max(30, maxTeamMembersState.value.toDouble()), + divisions: + max(30, maxTeamMembersState.value.toDouble()).toInt() - 1, + label: maxTeamMembersState.value.toString(), + ), + const SizedBox(height: 8), + Row( + children: [ + const Text("Schematic Type: "), + TextButton( + onPressed: editable + ? () async { + final types = + await ref.read(schematicTypesProvider.future); + final out = await showSearch( + context: context, + delegate: SchematicTypeSearchDelegate(types)); + if (out == "reset") { + schematicTypeState.value = null; + changed.value = true; + } else { + schematicTypeState.value = + out ?? schematicTypeState.value; + changed.value = true; + } + } + : null, + child: Text(schematicTypeState.value ?? "Select")), + ], + ), + const SizedBox(height: 8), + CheckboxListTile( + value: publicOnlyState.value, + onChanged: (value) { + publicOnlyState.value = value ?? publicOnlyState.value; + changed.value = true; + }, + title: const Text("Public Only"), + enabled: editable, + ), + const SizedBox(height: 8), + CheckboxListTile( + value: spectateSystemState.value, + onChanged: (value) { + spectateSystemState.value = value ?? spectateSystemState.value; + changed.value = true; + }, + title: const Text("Use Spectate System"), + enabled: editable, + ), + const SizedBox(height: 8), + Text("Teams", style: Theme.of(context).textTheme.headline6), + FutureBuilder>( + future: teams, + builder: (context, snap) { + if (snap.hasData) { + return Wrap( + children: [ + for (final team in snap.data!) + Padding( + padding: const EdgeInsets.all(8.0), + child: Chip( + label: Text(team.name, + style: TextStyle( + color: team.color.computeLuminance() > 0.5 + ? Colors.black + : Colors.white)), + backgroundColor: team.color, + ), + ) + ], + ); + } else if (snap.hasError) { + return ErrorComponent(snap.error!, null); + } else { + return const LinearProgressIndicator(); + } + }, + ), + const SizedBox(height: 8), + Text("Fights", style: Theme.of(context).textTheme.headline6), + _EventFightList(event: event), + ], + ), + ), + ); + } +} + +class _EventFightList extends HookConsumerWidget { + final Event event; + + const _EventFightList({Key? key, required this.event}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fightsRefresher = useState(0); + final fights = useMemoized(() { + return ref + .read(eventRepositoryProvider.future) + .then((value) => value.getFightOfEvent(event.id)); + }, [fightsRefresher.value]); + + return FutureBuilder>( + future: fights, + builder: (context, data) { + if (data.hasError) { + return ErrorComponent(data.error!, null); + } else if (data.hasData) { + return ListView( + shrinkWrap: true, + children: [ + const SizedBox(height: 8), + FloatingActionButton.extended( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return _AddFightWidget( + event, + () => fightsRefresher.value++, + ); + }, + ); + }, + label: const Text("Add Fight"), + icon: const Icon(Icons.add), + ), + const SizedBox(height: 8), + for (final fight in data.data!) + ListTile( + title: + Text("${fight.blueTeam.name} vs ${fight.redTeam.name}"), + subtitle: fight.score != 0 + ? Text("Winner: ${fight.winner.name}") + : Text(fight.start.toString()), + onTap: () { + showDialog( + context: context, + builder: (context) { + return EditEventFightDialog( + fight: fight, fightsRefresher: fightsRefresher); + }); + }, + tileColor: fight.score != 0 ? fight.winner.color : null, + ) + ], + ); + } else { + return const LinearProgressIndicator(); + } + }); + } +} + +class EditEventFightDialog extends HookConsumerWidget { + const EditEventFightDialog({ + Key? key, + required this.fight, + required this.fightsRefresher, + }) : super(key: key); + + final EventFight fight; + final ValueNotifier fightsRefresher; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final start = useState(fight.start); + final mode = useState(fight.gameMode); + final map = useState(fight.map); + final referrer = useState(fight.fightLeaderId); + + return AlertDialog( + title: Text("${fight.blueTeam.name} vs ${fight.redTeam.name}"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + const Text("Start"), + const SizedBox(height: 8), + DateTimeEditor((p0) { + start.value = p0; + }, start.value, true), + const SizedBox(height: 8), + TextButton( + onPressed: () { + start.value = start.value.add(const Duration(seconds: 30)); + }, + child: const Text("Add 30 Seconds"), + ), + const SizedBox(height: 8), + const Text("Game Mode"), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () async { + final modes = await ref.read(fightServersProvider.future); + final out = await showSearch( + context: context, delegate: GamemodeSearchDelegate(modes)); + if (out != null) { + mode.value = out; + } + }, + child: Text(mode.value), + ), + const SizedBox(height: 8), + const Text("Map"), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () async { + final maps = await ref.read(mapsProvider.future); + final out = await showSearch( + context: context, + delegate: MapSearchDelegate(maps[mode.value] ?? [])); + if (out != null) { + map.value = out; + fightsRefresher.value++; + } + }, + child: Text(map.value), + ), + const SizedBox(height: 8), + const Text("Kampfleiter"), + const SizedBox(height: 8), + TextButton( + onPressed: () async { + final users = await ref.read(usersProvider.future); + final user = await showSearch( + context: context, delegate: UserSearchDelegate(users)); + if (user != null) { + referrer.value = user.id; + fightsRefresher.value++; + } + }, + child: + Text(referrer.value == 0 ? "None" : referrer.value.toString()), + ), + ], + ), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + ref.read(eventRepositoryProvider.future).then( + (value) => value.deleteFight(fight.id).then( + (value) { + fightsRefresher.value++; + }, + ), + ); + }, + child: const Text("Delete")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel")), + TextButton( + onPressed: () async { + var nav = Navigator.of(context); + final repo = await ref.read(eventRepositoryProvider.future); + await repo.updateFight( + fight.id, start.value, mode.value, map.value, referrer.value); + fightsRefresher.value++; + nav.pop(); + }, + child: const Text("Save")), + ], + ); + } +} + +class _AddFightWidget extends HookConsumerWidget { + final void Function() onAdded; + final Event event; + const _AddFightWidget( + this.event, + this.onAdded, { + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final gamemode = useState(null); + final map = useState(null); + final blueTeam = useState(null); + final redTeam = useState(null); + final date = useState(event.start); + + final canCreate = useMemoized(() { + return gamemode.value != null && + map.value != null && + blueTeam.value != null && + redTeam.value != null; + }, [gamemode.value, map.value, blueTeam.value, redTeam.value]); + + return AlertDialog( + title: const Text("Add Fight"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("Gamemode"), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () async { + final modes = await ref.read(fightServersProvider.future); + final mode = await showSearch( + context: context, delegate: GamemodeSearchDelegate(modes)); + if (mode != null) { + gamemode.value = mode; + } + }, + child: Text(gamemode.value ?? "Select"), + ), + const SizedBox(height: 8), + const Text("Map"), + const SizedBox(height: 8), + ElevatedButton( + onPressed: gamemode.value != null + ? () async { + final maps = await ref.read(mapsProvider.future); + final sMap = await showSearch( + context: context, + delegate: MapSearchDelegate(maps[gamemode.value]!)); + if (sMap != null) { + map.value = sMap; + } + } + : null, + child: Text(map.value ?? "Select"), + ), + const SizedBox(height: 8), + const Text("Blue Team"), + _TeamSelector(event, blueTeam.value, (p0) => blueTeam.value = p0), + const SizedBox(height: 8), + const Text("Red Team"), + _TeamSelector(event, redTeam.value, (p0) => redTeam.value = p0), + const SizedBox(height: 8), + DateTimeEditor((p0) => date.value = p0, date.value, true), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel")), + TextButton( + onPressed: canCreate + ? () async { + final nav = Navigator.of(context); + final repo = await ref.read(eventRepositoryProvider.future); + await repo.createFight( + event.id, + date.value, + gamemode.value!, + map.value!, + blueTeam.value!, + redTeam.value!); + onAdded.call(); + nav.pop(); + } + : null, + child: const Text("Add")), + ], + ); + } +} + +class _TeamSelector extends HookConsumerWidget { + final Event event; + final ShortTeam? selectedTeam; + final void Function(ShortTeam) onSelected; + const _TeamSelector( + this.event, + this.selectedTeam, + this.onSelected, { + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ElevatedButton( + onPressed: () async { + final teams = await ref + .read(eventRepositoryProvider.future) + .then((value) => value.getTeams(event.id)); + final team = await showSearch( + context: context, delegate: TeamSearchDelegate(teams)); + if (team != null) { + onSelected(team); + } + }, + child: Text(selectedTeam?.name ?? "Select Team"), + ); + } +} + +class DateTimeEditor extends HookConsumerWidget { + final void Function(DateTime) onChanged; + final DateTime initialDate; + final bool enabled; + const DateTimeEditor(this.onChanged, this.initialDate, this.enabled, + {Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Row( + children: [ + TextButton( + onPressed: enabled + ? () async { + final date = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (date != null) { + onChanged(date); + } + } + : null, + child: Text(initialDate.toString()), + ), + IconButton( + onPressed: enabled + ? () async { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: initialDate.hour, minute: initialDate.minute), + ); + if (time != null) { + final changed = DateTime( + initialDate.year, + initialDate.month, + initialDate.day, + time.hour, + time.minute); + onChanged(changed); + } + } + : null, + icon: const Icon(Icons.schedule)), + ], + ); + } +} diff --git a/lib/src/screens/home.dart b/lib/src/screens/home.dart index c3fbb42..565d6a6 100644 --- a/lib/src/screens/home.dart +++ b/lib/src/screens/home.dart @@ -1,5 +1,9 @@ +import 'package:dev_server_starter/src/provider/events.dart'; +import 'package:dev_server_starter/src/screens/components/events_list.dart'; +import 'package:dev_server_starter/src/screens/components/server_list.dart'; import 'package:dev_server_starter/src/screens/console.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -11,19 +15,19 @@ class HomeScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final userData = ref.read(userDataProvider).value; - final servers = ref.watch(serversProvider); + final navRailIndex = useState(0); return Scaffold( appBar: AppBar( - title: Text('Home: ${userData?.username ?? ""}'), + title: const Text('Home'), actions: [ IconButton( onPressed: servers.isLoading ? null : () { ref.refresh(serversProvider); + ref.refresh(eventsListProvider); }, icon: const Icon(Icons.refresh), ), @@ -59,45 +63,42 @@ class HomeScreen extends HookConsumerWidget { ), ], ), - body: RefreshIndicator( - onRefresh: () { - ref.refresh(serversProvider); - return Future.value(); - }, - child: SingleChildScrollView( - child: servers.when( - data: (data) { - return Column( - children: [ - for (final server in data) - ListTile( - leading: const Icon(Icons.dns), - title: Text(server), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ConsoleScreen(ServerStartParameters(server)), - ), - ); - }, - ), - ], - ); - }, - error: (err, stack) { - return Center( - child: Text(err.toString()), - ); - }, - loading: () { - return const Center( - child: CircularProgressIndicator(), - ); + body: Row( + children: [ + NavigationRail( + elevation: 1, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.dns), + label: Text('Server'), + ), + NavigationRailDestination( + icon: Icon(Icons.calendar_today), + label: Text('Events'), + ), + ], + labelType: NavigationRailLabelType.selected, + trailing: IconButton( + icon: const Icon(Icons.person), + onPressed: () { + Navigator.pushNamed(context, "/settings"); + }, + ), + selectedIndex: navRailIndex.value, + onDestinationSelected: (index) { + navRailIndex.value = index; }, ), - ), + Expanded( + child: IndexedStack( + index: navRailIndex.value, + children: const [ + ServerListComponent(), + EventListComponent(), + ], + ), + ), + ], ), ); } diff --git a/lib/src/screens/login.dart b/lib/src/screens/login.dart index 3f4a2e9..5c5c5b9 100644 --- a/lib/src/screens/login.dart +++ b/lib/src/screens/login.dart @@ -8,6 +8,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../main.dart'; + class LoginScreenWidget extends HookConsumerWidget { const LoginScreenWidget({ Key? key, @@ -157,6 +159,3 @@ class LoginScreenWidget extends HookConsumerWidget { }); } } - -String? get userHome => - Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; diff --git a/lib/src/screens/search_delegates.dart b/lib/src/screens/search_delegates.dart new file mode 100644 index 0000000..86e1a04 --- /dev/null +++ b/lib/src/screens/search_delegates.dart @@ -0,0 +1,305 @@ +import 'package:dev_server_starter/src/provider/types.dart'; +import 'package:flutter/material.dart'; + +import '../provider/events.dart'; + +class SchematicTypeSearchDelegate extends SearchDelegate { + final List types; + + SchematicTypeSearchDelegate(this.types); + + @override + List? buildActions(BuildContext context) { + return [ + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + query = ""; + }, + ), + ]; + } + + @override + Widget? buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + close(context, null); + }, + ); + } + + @override + Widget buildResults(BuildContext context) { + return _buildList(context); + } + + @override + Widget buildSuggestions(BuildContext context) { + return _buildList(context); + } + + Widget _buildList(BuildContext context) { + final out = types + .where((element) => element.toLowerCase().contains(query.toLowerCase())) + .toList(); + return ListView.builder( + itemCount: out.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return ListTile( + title: const Text("Reset"), + leading: const Icon(Icons.clear), + onTap: () { + close(context, "reset"); + }, + ); + } + + final type = out[index - 1]; + + return ListTile( + title: Text(type), + onTap: () { + close(context, type); + }, + ); + }, + ); + } +} + +class GamemodeSearchDelegate extends SearchDelegate { + final List modes; + + GamemodeSearchDelegate(this.modes); + + @override + List? buildActions(BuildContext context) { + return [ + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + query = ""; + }, + ), + ]; + } + + @override + Widget? buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + close(context, null); + }, + ); + } + + @override + Widget buildResults(BuildContext context) { + return _buildList(context); + } + + @override + Widget buildSuggestions(BuildContext context) { + return _buildList(context); + } + + Widget _buildList(BuildContext context) { + final out = modes + .where((element) => element.toLowerCase().contains(query.toLowerCase())) + .toList(); + return ListView.builder( + itemCount: out.length, + itemBuilder: (context, index) { + final type = out[index]; + + return ListTile( + title: Text(type), + onTap: () { + close(context, type); + }, + ); + }, + ); + } +} + +class MapSearchDelegate extends SearchDelegate { + final List maps; + + MapSearchDelegate(this.maps); + + @override + List? buildActions(BuildContext context) { + return [ + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + query = ""; + }, + ), + ]; + } + + @override + Widget? buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + close(context, null); + }, + ); + } + + @override + Widget buildResults(BuildContext context) { + return _buildList(context); + } + + @override + Widget buildSuggestions(BuildContext context) { + return _buildList(context); + } + + Widget _buildList(BuildContext context) { + final out = maps + .where((element) => element.toLowerCase().contains(query.toLowerCase())) + .toList(); + return ListView.builder( + itemCount: out.length, + itemBuilder: (context, index) { + final type = out[index]; + + return ListTile( + title: Text(type), + onTap: () { + close(context, type); + }, + ); + }, + ); + } +} + +class UserSearchDelegate extends SearchDelegate { + final List users; + + UserSearchDelegate(this.users); + + @override + List? buildActions(BuildContext context) { + return [ + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + query = ""; + }, + ) + ]; + } + + @override + Widget? buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + close(context, null); + }, + ); + } + + @override + Widget buildResults(BuildContext context) { + return ListView( + children: [ + for (final team in users) + if (team.name.toLowerCase().contains(query.toLowerCase())) + ListTile( + title: Text(team.name), + onTap: () { + close(context, team); + }, + ) + ], + ); + } + + @override + Widget buildSuggestions(BuildContext context) { + return ListView( + children: [ + for (final team in users) + if (team.name.toLowerCase().contains(query.toLowerCase())) + ListTile( + title: Text(team.name), + onTap: () { + close(context, team); + }, + ) + ], + ); + } +} + +class TeamSearchDelegate extends SearchDelegate { + final List teams; + + TeamSearchDelegate(this.teams); + + @override + List? buildActions(BuildContext context) { + return [ + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + query = ""; + }, + ) + ]; + } + + @override + Widget? buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + close(context, null); + }, + ); + } + + @override + Widget buildResults(BuildContext context) { + return ListView( + children: [ + for (final team in teams) + if (team.name.toLowerCase().contains(query.toLowerCase())) + ListTile( + title: Text(team.name), + onTap: () { + close(context, team); + }, + ) + ], + ); + } + + @override + Widget buildSuggestions(BuildContext context) { + return ListView( + children: [ + for (final team in teams) + if (team.name.toLowerCase().contains(query.toLowerCase())) + ListTile( + title: Text(team.name), + onTap: () { + close(context, team); + }, + ) + ], + ); + } +} diff --git a/lib/src/screens/userinfo.dart b/lib/src/screens/userinfo.dart new file mode 100644 index 0000000..dd8a293 --- /dev/null +++ b/lib/src/screens/userinfo.dart @@ -0,0 +1,213 @@ +import 'package:dev_server_starter/src/provider/user.dart'; +import 'package:dev_server_starter/src/screens/components/error.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../main.dart'; + +class SettingsScreen extends HookConsumerWidget { + const SettingsScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userDate = ref.watch(userDataProvider); + + return userDate.when( + data: (data) { + final userName = useTextEditingController(text: data.username); + final password = useTextEditingController(text: data.password ?? ""); + final privateKeyFile = useState(data.privateKeyFile); + final sqlUsername = + useTextEditingController(text: data.sqlUserName ?? ""); + final sqlPassword = + useTextEditingController(text: data.sqlPassword ?? ""); + final useDevDB = useState(data.useDevDB); + final uneditablePastEvents = useState(data.uneditablePastEvents); + + final changed = useState(false); + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + actions: [ + IconButton( + onPressed: changed.value + ? () async { + if (userName.text.isEmpty || + (privateKeyFile.value == null && + password.text.isEmpty)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Username and password or private key are required"), + ), + ); + return; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString("username", userName.text); + if (password.text.isNotEmpty) { + await prefs.setString("password", password.text); + } else { + await prefs.remove("password"); + } + if (privateKeyFile.value != null) { + await prefs.setString( + "privateKeyFile", privateKeyFile.value!); + } else { + await prefs.remove("privateKeyFile"); + } + if (sqlUsername.text.isNotEmpty) { + await prefs.setString( + "sqlUsername", sqlUsername.text); + } else { + await prefs.remove("sqlUsername"); + } + if (sqlPassword.text.isNotEmpty) { + await prefs.setString( + "sqlPassword", sqlPassword.text); + } else { + await prefs.remove("sqlPassword"); + } + await prefs.setBool("sqlUseDevDB", useDevDB.value); + await prefs.setBool( + "uneditablePastEvents", uneditablePastEvents.value); + ref.refresh(userDataProvider); + changed.value = false; + } + : null, + icon: const Icon(Icons.save), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TextField( + controller: userName, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Username', + ), + onChanged: (value) { + changed.value = true; + }, + ), + const SizedBox(height: 10), + TextField( + controller: password, + obscureText: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Password', + ), + onChanged: (value) { + changed.value = true; + }, + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowMultiple: false, + dialogTitle: "Select a private key", + initialDirectory: + userHome != null ? join(userHome!, ".ssh") : null, + ); + + if (result != null) { + final file = result.files.first; + privateKeyFile.value = file.path; + changed.value = true; + } + }, + child: + Text(privateKeyFile.value ?? "Select private key file"), + onLongPress: () async { + final remove = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Remove private key file?"), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text("Remove"), + ), + ], + ); + }); + if (remove) { + privateKeyFile.value = null; + changed.value = true; + } + }, + ), + const SizedBox(height: 10), + TextField( + controller: sqlUsername, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'SQL Username', + ), + onChanged: (value) { + changed.value = true; + }, + ), + const SizedBox(height: 10), + TextField( + controller: sqlPassword, + obscureText: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'SQL Password', + ), + onChanged: (value) { + changed.value = true; + }, + ), + CheckboxListTile( + title: const Text("Use dev database"), + value: useDevDB.value, + onChanged: (value) { + useDevDB.value = value ?? false; + changed.value = true; + }, + ), + CheckboxListTile( + title: const Text("Disable Past Events uneditable"), + value: uneditablePastEvents.value, + onChanged: (value) { + uneditablePastEvents.value = value ?? false; + changed.value = true; + }, + ), + ], + ), + ), + ), + ); + }, + error: (err, stack) => ErrorComponent(err, stack), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index a46afa3..61a7989 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "49.0.0" + version: "50.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "5.1.0" + version: "5.2.0" args: dependency: transitive description: @@ -28,7 +28,7 @@ packages: name: asn1lib url: "https://pub.dartlang.org" source: hosted - version: "1.2.1" + version: "1.4.0" async: dependency: transitive description: @@ -43,6 +43,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + buffer: + dependency: transitive + description: + name: buffer + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" build: dependency: transitive description: @@ -70,21 +77,21 @@ packages: name: build_resolvers url: "https://pub.dartlang.org" source: hosted - version: "2.0.10" + version: "2.1.0" build_runner: dependency: "direct dev" description: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "2.3.2" + version: "2.3.3" build_runner_core: dependency: transitive description: name: build_runner_core url: "https://pub.dartlang.org" source: hosted - version: "7.2.6" + version: "7.2.7" built_collection: dependency: transitive description: @@ -98,7 +105,7 @@ packages: name: built_value url: "https://pub.dartlang.org" source: hosted - version: "8.4.1" + version: "8.4.2" characters: dependency: transitive description: @@ -203,7 +210,7 @@ packages: name: file_picker url: "https://pub.dartlang.org" source: hosted - version: "5.2.2" + version: "5.2.4" fixnum: dependency: transitive description: @@ -243,7 +250,7 @@ packages: name: flutter_riverpod url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.1.1" flutter_test: dependency: "direct dev" description: flutter @@ -260,7 +267,7 @@ packages: name: freezed url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.3.2" freezed_annotation: dependency: transitive description: @@ -274,14 +281,14 @@ packages: name: frontend_server_client url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.2.0" glob: dependency: transitive description: name: glob url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" graphs: dependency: transitive description: @@ -295,7 +302,7 @@ packages: name: hooks_riverpod url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.1.1" http_multi_server: dependency: transitive description: @@ -337,7 +344,7 @@ packages: name: json_serializable url: "https://pub.dartlang.org" source: hosted - version: "6.5.3" + version: "6.5.4" lints: dependency: transitive description: @@ -379,7 +386,14 @@ packages: name: mime url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.0.3" + mysql_client: + dependency: "direct main" + description: + name: mysql_client + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.27" package_config: dependency: transitive description: @@ -407,7 +421,7 @@ packages: name: path_provider_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.20" + version: "2.0.22" path_provider_ios: dependency: transitive description: @@ -498,7 +512,7 @@ packages: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.3" pubspec_parse: dependency: transitive description: @@ -512,14 +526,14 @@ packages: name: quiver url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.2.1" riverpod: dependency: transitive description: name: riverpod url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.1.1" shared_preferences: dependency: "direct main" description: @@ -589,7 +603,7 @@ packages: name: shelf_web_socket url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.0.3" sky_engine: dependency: transitive description: flutter @@ -643,7 +657,7 @@ packages: name: stream_transform url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -672,6 +686,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + tuple: + dependency: transitive + description: + name: tuple + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" typed_data: dependency: transitive description: @@ -706,7 +727,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.1.3" xdg_directories: dependency: transitive description: @@ -722,7 +743,7 @@ packages: source: hosted version: "3.4.0" yaml: - dependency: transitive + dependency: "direct main" description: name: yaml url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index de0fc64..f7137e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,11 @@ name: dev_server_starter description: A new Flutter project. +authors: + - Chaoscaot publish_to: 'none' -version: 1.0.0+1 +version: 1.0.0 environment: sdk: '>=2.18.1 <3.0.0' @@ -21,6 +23,8 @@ dependencies: shared_preferences: ^2.0.15 file_picker: ^5.2.2 xterm: ^3.4.0 + mysql_client: ^0.0.27 + yaml: ^3.1.1 dev_dependencies: