diff --git a/lib/src/app.dart b/lib/src/app.dart index b24f088..4d98a5c 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:steamwar_multitool/src/screens/event.dart'; +import 'package:steamwar_multitool/src/screens/event_fights_graph.dart'; import 'screens/home.dart'; import 'screens/login.dart'; @@ -23,6 +24,16 @@ final _routes = GoRouter( } return EditEventScreen(eventId); }), + GoRoute( + path: "/event/:eventId/graph", + builder: (context, state) { + final eventId = int.tryParse(state.params['eventId']!); + if (eventId == null) { + context.go("/"); + return const SizedBox(); + } + return EventfightsGraph(eventId); + }), ], initialLocation: "/login", ); diff --git a/lib/src/provider/mods.dart b/lib/src/provider/mods.dart new file mode 100644 index 0000000..9e7d4ab --- /dev/null +++ b/lib/src/provider/mods.dart @@ -0,0 +1,99 @@ +import 'package:dio/dio.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:steamwar_multitool/src/provider/http.dart'; + +final modRepository = FutureProvider( + (ref) async => ModRepository(await ref.read(httpClient.future)), + dependencies: [httpClient]); + +final uncheckedMods = FutureProvider>((ref) async { + final repo = await ref.watch(modRepository.future); + return repo.listUnchecked(); +}, dependencies: [modRepository]); + +final mods = FutureProvider>((ref) async { + final repo = await ref.watch(modRepository.future); + return repo.listMods(); +}, dependencies: [modRepository]); + +class ModRepository { + final Dio _client; + + ModRepository(this._client); + + Future> listMods() async { + final res = await _client.get("/mods/all"); + return (res.data as List).map((e) => Mod.fromJson(e)).toList(); + } + + Future> listUnchecked() async { + final res = await _client.get("/mods/unchecked"); + return (res.data as List).map((e) => Mod.fromJson(e)).toList(); + } + + Future updateMod(Platform platform, String name, ModType type) { + return _client.put("/mods/${platform.name}/$name", data: { + "modType": type.name, + }); + } +} + +class Mod { + final Platform platform; + final String name; + final ModType type; + + Mod(this.platform, this.name, this.type); + + factory Mod.fromJson(Map json) { + return Mod( + Platform.fromOrdinal(json["platform"] as int), + json["modName"], + ModType.fromOrdinal(json["modType"] as int), + ); + } +} + +enum Platform { + FORGE, + LABYMOD, + FABRIC; + + static Platform fromOrdinal(int ordinal) { + switch (ordinal) { + case 0: + return FORGE; + case 1: + return LABYMOD; + case 2: + return FABRIC; + default: + throw Exception("Invalid ordinal"); + } + } +} + +enum ModType { + UNKLASSIFIED, + GREEN, + YELLOW, + RED, + YOUTUBER_ONLY; + + static ModType fromOrdinal(int ordinal) { + switch (ordinal) { + case 0: + return UNKLASSIFIED; + case 1: + return GREEN; + case 2: + return YELLOW; + case 3: + return RED; + case 4: + return YOUTUBER_ONLY; + default: + throw Exception("Invalid ordinal"); + } + } +} diff --git a/lib/src/screens/components/error.dart b/lib/src/screens/components/error.dart index 28abc33..177c40e 100644 --- a/lib/src/screens/components/error.dart +++ b/lib/src/screens/components/error.dart @@ -11,11 +11,13 @@ class ErrorComponent extends StatelessWidget { return Container( color: Colors.red, child: Center( - child: Column( - children: [ - SelectableText(error.toString()), - if (stackTrace != null) Text(stackTrace.toString()), - ], + child: SingleChildScrollView( + child: Column( + children: [ + SelectableText(error.toString()), + if (stackTrace != null) Text(stackTrace.toString()), + ], + ), ), ), ); diff --git a/lib/src/screens/components/events_list.dart b/lib/src/screens/components/events_list.dart index e3ca084..2319a93 100644 --- a/lib/src/screens/components/events_list.dart +++ b/lib/src/screens/components/events_list.dart @@ -22,7 +22,8 @@ class EventListComponent extends HookConsumerWidget { children: [ FloatingActionButton.extended( onPressed: () { - showDialog(context: context, builder: (context) => EventDialog()); + showDialog( + context: context, builder: (context) => const EventDialog()); }, label: const Text("Create Event"), icon: const Icon(Icons.add), diff --git a/lib/src/screens/components/mod_list.dart b/lib/src/screens/components/mod_list.dart new file mode 100644 index 0000000..d5ad1d9 --- /dev/null +++ b/lib/src/screens/components/mod_list.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:steamwar_multitool/src/provider/mods.dart'; + +import 'error.dart'; + +class ModListComponent extends HookConsumerWidget { + const ModListComponent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final list = ref.watch(uncheckedMods); + + return list.when( + data: (data) { + return ListView( + children: [ + Center( + child: Text("WIP", + style: Theme.of(context).textTheme.headline5)), + ...data + .map( + (e) => ListTile( + title: Text(e.name), + subtitle: Text(e.platform.name), + ), + ) + .toList() + ], + ); + }, + error: (err, stack) => ErrorComponent(err, stack), + loading: () => const Center(child: CircularProgressIndicator())); + } +} diff --git a/lib/src/screens/event.dart b/lib/src/screens/event.dart index cdebfd1..17c2f98 100644 --- a/lib/src/screens/event.dart +++ b/lib/src/screens/event.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; import 'package:steamwar_multitool/src/util/event_fight_exporter.dart'; import '../../main.dart'; @@ -302,7 +303,8 @@ class _EventScreen extends HookConsumerWidget { title: const Text("Use Spectate System"), ), const SizedBox(height: 8), - Text("Teams", style: Theme.of(context).textTheme.headline6), + Text("Teams (${eventData.teams.length})", + style: Theme.of(context).textTheme.headline6), Wrap( children: [ for (final team in eventData.teams) @@ -340,6 +342,17 @@ class _EventFightList extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final event = eventData.event; final fights = useState>(eventData.fights); + final selected = useState>([]); + + final currentCheckBoxState = useMemoized(() { + if (selected.value.isEmpty) { + return false; + } else if (selected.value.length == fights.value.length) { + return true; + } else { + return null; + } + }, [selected.value, fights.value]); void update() async { final repo = await ref.read(eventRepositoryProvider.future); @@ -351,30 +364,213 @@ class _EventFightList extends HookConsumerWidget { const SizedBox(height: 8), Row( children: [ - Expanded( - child: FloatingActionButton.extended( - onPressed: () { - showDialog( - context: context, - builder: (context) { - return _AddFightWidget( - eventData, - () => update(), - ); + const SizedBox(width: 16), + Checkbox( + value: currentCheckBoxState, + onChanged: (v) { + if (v == null) { + selected.value = []; + } else if (v) { + selected.value = fights.value + .where((element) => element.start.isAfter(DateTime.now())) + .map((e) => e.id) + .toList(); + if (selected.value.isEmpty) { + selected.value = fights.value.map((e) => e.id).toList(); + } + } else { + selected.value = fights.value.map((e) => e.id).toList(); + } + }, + tristate: true, + ), + const SizedBox(width: 8), + ButtonBar( + children: [ + Tooltip( + message: "Reschedule Fights", + child: IconButton( + onPressed: () { + if (selected.value.isEmpty) { + return; + } + showDialog( + context: context, + builder: (context) { + return HookBuilder(builder: (context) { + final lowest = useMemoized( + () => DateTime.fromMillisecondsSinceEpoch( + fights.value + .where((element) => selected.value + .contains(element.id)) + .map((e) => + e.start.millisecondsSinceEpoch) + .reduce(min)), + [fights.value]); + final date = useState(lowest); + + final offset = useMemoized( + () => date.value.difference(lowest), + [date.value, lowest]); + + return AlertDialog( + title: const Text("Reshedule Fights"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DateTimeEditor( + mainAxisAlignment: + MainAxisAlignment.center, + (p0) { + date.value = p0; + }, + date.value, + true, + ), + const SizedBox(height: 8), + Text( + "${offset.isNegative ? "-" : ""}${offset.inHours.abs().toString().padLeft(2, "0")}:${offset.inMinutes.remainder(60).abs().toInt().toString().padLeft(2, "0")}:${offset.inSeconds.remainder(60).abs().toInt().toString().padLeft(2, "0")}"), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () async { + final selectedFights = fights.value + .where((element) => selected.value + .contains(element.id)) + .toList(); + final repo = await ref + .read(eventRepositoryProvider.future); + for (final fight in selectedFights) { + repo.updateFight( + fight.id, + fight.start.add(offset), + fight.gameMode, + fight.map, + fight.kampfleiter.id, + fight.blueTeam, + fight.redTeam); + } + update(); + Navigator.of(context).pop(); + }, + child: const Text("Reshedule"), + ), + ], + ); + }); + }); }, - ); - }, - label: const Text("Add Fight"), - icon: const Icon(Icons.add), - ), + icon: const Icon(Icons.event_note), + ), + ), + Tooltip( + message: "Change Kampfleiter", + child: IconButton( + onPressed: () async { + if (selected.value.isEmpty) { + return; + } + + final kampfleiter = await showSearch( + context: context, + delegate: UserSearchDelegate( + await ref.read(usersProvider.future))); + + if (kampfleiter == null) { + return; + } else { + final repo = + await ref.read(eventRepositoryProvider.future); + for (final fight in fights.value) { + if (selected.value.contains(fight.id)) { + repo.updateFight( + fight.id, + fight.start, + fight.gameMode, + fight.map, + kampfleiter.id, + fight.blueTeam, + fight.redTeam); + } + } + update(); + } + }, + icon: const Icon(Icons.person)), + ), + Tooltip( + message: "Delete Fights", + child: IconButton( + onPressed: () { + if (selected.value.isEmpty) { + return; + } + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Delete Fights"), + content: const Text( + "Do you really want to delete the selected fights?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () async { + final repo = await ref + .read(eventRepositoryProvider.future); + for (final fight in selected.value) { + repo.deleteFight(fight); + } + update(); + Navigator.of(context).pop(); + }, + child: const Text("Delete", + style: TextStyle(color: Colors.red)), + ), + ], + ); + }); + }, + icon: const Icon(Icons.delete, color: Colors.red), + ), + ), + ], + ), + const Spacer(), + FloatingActionButton.extended( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return _AddFightWidget( + eventData, + () => update(), + ); + }, + ); + }, + label: const Text("Add Fight"), + icon: const Icon(Icons.add), ), const SizedBox(width: 8), PopupMenuButton( itemBuilder: (context) { - return [ + return const [ PopupMenuItem( - child: const Text("Export Data"), value: "export", + child: Text("Export Data"), ), ]; }, @@ -386,7 +582,12 @@ class _EventFightList extends HookConsumerWidget { ) ], ), + const Divider(), const SizedBox(height: 8), + if (fights.value.isEmpty) + const Center( + child: Text("No fights yet"), + ), for (final fight in fights.value) ListTile( title: Text( @@ -427,6 +628,17 @@ class _EventFightList extends HookConsumerWidget { }, tileColor: fight.score != 0 ? fight.winner.color.withOpacity(0.5) : null, + leading: Checkbox( + value: selected.value.contains(fight.id), + onChanged: (value) { + if (value ?? false) { + selected.value = [...selected.value, fight.id]; + } else { + selected.value = selected.value + .where((element) => element != fight.id) + .toList(); + } + }), ) ], ); @@ -474,7 +686,7 @@ class EditEventFightDialog extends HookConsumerWidget { const SizedBox(height: 8), DateTimeEditor((p0) { start.value = p0; - }, start.value, true), + }, start.value, true, mainAxisAlignment: MainAxisAlignment.center), const SizedBox(height: 8), TextButton( onPressed: () { @@ -540,6 +752,7 @@ class EditEventFightDialog extends HookConsumerWidget { title: const Text("Delete Fight?"), content: const Text( "Do you really want to delete this fight?"), + icon: const Icon(Icons.warning), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), @@ -561,7 +774,7 @@ class EditEventFightDialog extends HookConsumerWidget { Navigator.of(context).pop(); } }, - child: const Text("Delete")), + child: const Text("Delete", style: TextStyle(color: Colors.red))), TextButton( onPressed: () { Navigator.of(context).pop(); @@ -652,7 +865,8 @@ class _AddFightWidget extends HookConsumerWidget { _TeamSelector(event, redTeam.value, (p0) => redTeam.value = p0, eventData.teams), const SizedBox(height: 8), - DateTimeEditor((p0) => date.value = p0, date.value, true), + DateTimeEditor((p0) => date.value = p0, date.value, true, + mainAxisAlignment: MainAxisAlignment.center), const SizedBox(height: 8), const Text("Kampfleiter"), const SizedBox(height: 8), @@ -733,13 +947,15 @@ class DateTimeEditor extends HookConsumerWidget { final void Function(DateTime) onChanged; final DateTime initialDate; final bool enabled; + final MainAxisAlignment mainAxisAlignment; const DateTimeEditor(this.onChanged, this.initialDate, this.enabled, - {Key? key}) + {Key? key, this.mainAxisAlignment = MainAxisAlignment.start}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { return Row( + mainAxisAlignment: mainAxisAlignment, children: [ TextButton( onPressed: enabled diff --git a/lib/src/screens/event_fights_graph.dart b/lib/src/screens/event_fights_graph.dart new file mode 100644 index 0000000..2387533 --- /dev/null +++ b/lib/src/screens/event_fights_graph.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:steamwar_multitool/src/provider/events.dart'; + +class EventfightsGraph extends HookConsumerWidget { + final int eventId; + const EventfightsGraph(this.eventId, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eventFuture = useMemoized(() => ref + .watch(eventRepositoryProvider.future) + .then((value) => value.getEvent(eventId))); + return FutureBuilder( + builder: (context, snapshot) { + if (snapshot.hasData) { + return _EventFightsGraph(snapshot.data!); + } else if (snapshot.hasError) { + return const Text("Error"); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + future: eventFuture); + } +} + +class EventFightPosition { + final Offset position; + final int fightId; + + EventFightPosition(this.position, this.fightId); +} + +class _EventFightsGraph extends HookConsumerWidget { + final EventExtended event; + const _EventFightsGraph(this.event, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + //final positions = useState(List.generate(event.fights.length, + // (index) => EventFightPosition(Offset.zero, event.fights[index].id))); + final positions = useState([ + EventFightPosition(Offset.zero, 0), + EventFightPosition(Offset.zero, 1) + ]); + + return Scaffold( + appBar: AppBar( + title: Text(event.event.name), + ), + body: Stack( + children: [ + for (final fight in positions.value) + Positioned( + top: fight.position.dy, + left: fight.position.dx, + child: GestureDetector( + onPanUpdate: (details) { + positions.value = [ + ...positions.value + .where((element) => element.fightId != fight.fightId), + EventFightPosition( + fight.position + (details.delta * 2), fight.fightId) + ]; + }, + child: Container( + color: Colors.red, + width: 100, + height: 100, + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/src/screens/home.dart b/lib/src/screens/home.dart index f249fc8..ad7ea51 100644 --- a/lib/src/screens/home.dart +++ b/lib/src/screens/home.dart @@ -5,11 +5,13 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:steamwar_multitool/src/provider/mods.dart'; import '../provider/events.dart'; import '../provider/server.dart'; import '../provider/user.dart'; import 'components/events_list.dart'; +import 'components/mod_list.dart'; class HomeScreen extends HookConsumerWidget { const HomeScreen({Key? key}) : super(key: key); @@ -46,6 +48,7 @@ class HomeScreen extends HookConsumerWidget { ? null : () { ref.invalidate(eventsListProvider); + ref.invalidate(uncheckedMods); }, icon: const Icon(Icons.refresh), ), @@ -91,8 +94,8 @@ class HomeScreen extends HookConsumerWidget { label: Text('Events'), ), NavigationRailDestination( - icon: Icon(Icons.dns), - label: Text('Server'), + icon: Icon(Icons.developer_mode), + label: Text('Mods'), ), ], labelType: NavigationRailLabelType.selected, @@ -114,6 +117,7 @@ class HomeScreen extends HookConsumerWidget { //ServerListComponent(), EventListComponent(), Placeholder(), + //ModListComponent(), ], ), ), diff --git a/lib/src/screens/userinfo.dart b/lib/src/screens/userinfo.dart index 80332be..01a7fbb 100644 --- a/lib/src/screens/userinfo.dart +++ b/lib/src/screens/userinfo.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -46,6 +47,10 @@ class SettingsScreen extends HookConsumerWidget { icon: const Icon(Icons.save), ), ], + leading: IconButton( + onPressed: () => context.go("/"), + icon: Icon(Icons.arrow_back), + ), ), body: Padding( padding: const EdgeInsets.all(16.0),