diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index 8d2926fd..5f0ac058 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -38,6 +38,7 @@ import { ServerConnectionContext } from "./components/ServerConnection";
import { ServerDataContext } from "./components/ServerData";
import { AlertModal } from "./components/AlertModal";
import { Groups } from "./components/Groups";
+import { Achievements } from "./components/Achievements";
const routes = [
{
@@ -64,6 +65,12 @@ const routes = [
icon: faLocationDot,
name: "Challenges",
},
+ {
+ path: "/achievements",
+ element: ,
+ icon: faTrophy,
+ name: "Achievements",
+ },
{
path: "/users",
element: ,
diff --git a/admin/src/all.dto.ts b/admin/src/all.dto.ts
index 35059e48..239f7209 100644
--- a/admin/src/all.dto.ts
+++ b/admin/src/all.dto.ts
@@ -103,6 +103,10 @@ export interface RequestAchievementDataDto {
achievements: string[];
}
+export interface RequestAchievementTrackerDataDto {
+ achievements?: string[];
+}
+
export interface LoginDto {
idToken: string;
noRegister: boolean;
@@ -121,9 +125,7 @@ export interface RefreshTokenDto {
refreshToken: string;
}
-export interface CompletedChallengeDto {
- challengeId: string;
-}
+export interface CompletedChallengeDto {}
export interface ChallengeDto {
id: string;
@@ -230,7 +232,7 @@ export interface EventTrackerDto {
eventId: string;
isRanked: boolean;
hintsUsed: number;
- curChallengeId: string;
+ curChallengeId?: string;
prevChallenges: PrevChallengeDto[];
}
@@ -261,7 +263,7 @@ export interface GroupMemberDto {
id: string;
name: string;
points: number;
- curChallengeId: string;
+ curChallengeId?: string;
}
export interface GroupDto {
@@ -293,6 +295,7 @@ export interface OrganizationDto {
members?: string[];
events?: string[];
managers?: string[];
+ achivements?: string[];
}
export interface RequestOrganizationDataDto {
diff --git a/admin/src/components/Achievements.tsx b/admin/src/components/Achievements.tsx
new file mode 100644
index 00000000..f60ee56e
--- /dev/null
+++ b/admin/src/components/Achievements.tsx
@@ -0,0 +1,287 @@
+import { useContext, useMemo, useState } from "react";
+import { DeleteModal } from "./DeleteModal";
+import {
+ EntryModal,
+ EntryForm,
+ NumberEntryForm,
+ OptionEntryForm,
+ FreeEntryForm,
+ DateEntryForm,
+} from "./EntryModal";
+import { HButton } from "./HButton";
+import {
+ ButtonSizer,
+ CenterText,
+ ListCardBody,
+ ListCardBox,
+ ListCardButtons,
+ ListCardDescription,
+ ListCardTitle,
+} from "./ListCard";
+import { SearchBar } from "./SearchBar";
+import { ServerDataContext } from "./ServerData";
+
+import { compareTwoStrings } from "string-similarity";
+import {
+ AchievementDto,
+ AchievementTypeDto,
+ ChallengeLocationDto,
+} from "../all.dto";
+import { AlertModal } from "./AlertModal";
+
+const locationOptions = [
+ ChallengeLocationDto.ENG_QUAD,
+ ChallengeLocationDto.ARTS_QUAD,
+ ChallengeLocationDto.AG_QUAD,
+ ChallengeLocationDto.NORTH_CAMPUS,
+ ChallengeLocationDto.WEST_CAMPUS,
+ ChallengeLocationDto.COLLEGETOWN,
+ ChallengeLocationDto.ITHACA_COMMONS,
+ ChallengeLocationDto.ANY,
+];
+
+const achievementOptions = [
+ AchievementTypeDto.TOTAL_CHALLENGES,
+ AchievementTypeDto.TOTAL_CHALLENGES_OR_JOURNEYS,
+ AchievementTypeDto.TOTAL_JOURNEYS,
+ AchievementTypeDto.TOTAL_POINTS,
+];
+
+function AchiemementCard(props: {
+ achievement: AchievementDto;
+ onSelect: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+}) {
+ return (
+ <>
+
+
+ {props.achievement.name}
+
+
+ {props.achievement.eventId ? "UNLINK EVENT" : "LINK EVENT"}
+
+
+
+
+ {props.achievement.description}
+
+
+ Id: {props.achievement.id}
+ Required Points/Event Completions:{" "}
+ {props.achievement.requiredPoints}
+ Linked Event ID: {props.achievement.eventId ?? "NONE"}
+ Location Type: {props.achievement.locationType}
+ Achievement Type: {props.achievement.achievementType}
+
+
+ DELETE
+
+ EDIT
+
+
+
+ >
+ );
+}
+
+// Default Form Creation
+function makeForm() {
+ return [
+ { name: "Name", characterLimit: 256, value: "" },
+ { name: "Description", characterLimit: 2048, value: "" },
+ {
+ name: "Location Type",
+ options: locationOptions as string[],
+ value: 0,
+ },
+ {
+ name: "Achievement Type",
+ options: achievementOptions as string[],
+ value: 0,
+ },
+ { name: "Required Points", value: 1, min: 1, max: 999 },
+ ] as EntryForm[];
+}
+
+// Form to DTO Conversion
+function fromForm(form: EntryForm[], id: string): AchievementDto {
+ return {
+ id,
+ imageUrl: "",
+ name: (form[0] as FreeEntryForm).value,
+ description: (form[1] as FreeEntryForm).value,
+ locationType: locationOptions[(form[2] as OptionEntryForm).value],
+ achievementType: achievementOptions[(form[3] as OptionEntryForm).value],
+ requiredPoints: (form[4] as NumberEntryForm).value,
+ };
+}
+
+// DTO to Form Conversion
+function toForm(achievement: AchievementDto) {
+ return [
+ { name: "Name", characterLimit: 256, value: achievement.name! },
+ {
+ name: "Description",
+ characterLimit: 2048,
+ value: achievement.description!,
+ },
+ {
+ name: "Location Type",
+ options: locationOptions as string[],
+ value: locationOptions.indexOf(achievement.locationType!),
+ },
+ {
+ name: "Achievement Type",
+ options: achievementOptions as string[],
+ value: achievementOptions.indexOf(achievement.achievementType!),
+ },
+ {
+ name: "Required Points",
+ value: achievement.requiredPoints!,
+ min: 1,
+ max: 999,
+ },
+ ] as EntryForm[];
+}
+
+export function Achievements() {
+ const serverData = useContext(ServerDataContext);
+ const [isCreateModalOpen, setCreateModalOpen] = useState(false);
+ const [isEditModalOpen, setEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [selectModalOpen, setSelectModalOpen] = useState(false);
+ const [isLinkedModalOpen, setLinkedModalOpen] = useState(false);
+ const [form, setForm] = useState(() => makeForm());
+ const [currentId, setCurrentId] = useState("");
+ const [query, setQuery] = useState("");
+ const selectedOrg = serverData.organizations.get(serverData.selectedOrg);
+
+ return (
+ <>
+ setSelectModalOpen(false)}
+ />
+ setLinkedModalOpen(false)}
+ />
+ {
+ serverData.updateAchievement({
+ ...fromForm(form, ""),
+ initialOrganizationId: serverData.selectedOrg,
+ });
+ setCreateModalOpen(false);
+ }}
+ onCancel={() => {
+ setCreateModalOpen(false);
+ }}
+ form={form}
+ />
+ {
+ const oldAchievement = serverData.achievements.get(currentId)!;
+ serverData.updateAchievement({
+ ...oldAchievement,
+ ...fromForm(form, currentId),
+ });
+ setEditModalOpen(false);
+ }}
+ onCancel={() => {
+ setEditModalOpen(false);
+ }}
+ form={form}
+ />
+ setDeleteModalOpen(false)}
+ onDelete={() => {
+ serverData.deleteAchievement(currentId);
+ setDeleteModalOpen(false);
+ }}
+ />
+ {
+ if (!selectedOrg) {
+ setSelectModalOpen(true);
+ return;
+ }
+ setForm(makeForm());
+ setCreateModalOpen(true);
+ }}
+ onSearch={(query) => setQuery(query)}
+ />
+ {serverData.selectedOrg === "" ? (
+ Select an organization to view achievements
+ ) : serverData.organizations.get(serverData.selectedOrg) ? (
+ serverData.organizations?.get(serverData.selectedOrg)?.achivements
+ ?.length === 0 && (
+ No achievements in organization
+ )
+ ) : (
+ Error getting achievements
+ )}
+ {Array.from(
+ serverData.organizations
+ .get(serverData.selectedOrg)
+ ?.achivements?.map(
+ (achId: string) => serverData.achievements.get(achId)!
+ )
+ .filter((ach?: AchievementDto) => !!ach) ?? []
+ )
+ .sort(
+ (a: AchievementDto, b: AchievementDto) =>
+ compareTwoStrings(b.name ?? "", query) -
+ compareTwoStrings(a.name ?? "", query) +
+ compareTwoStrings(b.description ?? "", query) -
+ compareTwoStrings(a.description ?? "", query)
+ )
+ .map((ach) => (
+ {
+ if (ach.eventId) {
+ serverData.updateAchievement({
+ ...ach,
+ eventId: "",
+ });
+
+ return;
+ }
+
+ if (serverData.selectedEvent === "") {
+ setLinkedModalOpen(true);
+ } else {
+ serverData.updateAchievement({
+ ...ach,
+ eventId: serverData.selectedEvent,
+ });
+ }
+ }}
+ onDelete={() => {
+ setCurrentId(ach.id);
+ setDeleteModalOpen(true);
+ }}
+ onEdit={() => {
+ setCurrentId(ach.id);
+ setForm(toForm(ach));
+ setEditModalOpen(true);
+ }}
+ />
+ ))}
+ >
+ );
+}
diff --git a/admin/src/components/ServerApi.tsx b/admin/src/components/ServerApi.tsx
index 173d4602..8045bb0b 100644
--- a/admin/src/components/ServerApi.tsx
+++ b/admin/src/components/ServerApi.tsx
@@ -17,6 +17,10 @@ export class ServerApi {
this.send("requestAchievementData", data);
}
+ requestAchievementTrackerData(data: dto.RequestAchievementTrackerDataDto) {
+ this.send("requestAchievementTrackerData", data);
+ }
+
updateAchievementData(data: dto.UpdateAchievementDataDto) {
this.send("updateAchievementData", data);
}
diff --git a/admin/src/components/ServerData.tsx b/admin/src/components/ServerData.tsx
index e17a123c..d02539cb 100644
--- a/admin/src/components/ServerData.tsx
+++ b/admin/src/components/ServerData.tsx
@@ -13,6 +13,7 @@ import {
GroupDto,
UserDto,
OrganizationDto,
+ AchievementDto,
} from "../all.dto";
import { ServerApi } from "./ServerApi";
@@ -21,6 +22,7 @@ import { ServerConnectionContext } from "./ServerConnection";
/** object to store user data fetched from server */
const defaultData = {
events: new Map(),
+ achievements: new Map(),
challenges: new Map(),
organizations: new Map(),
users: new Map(),
@@ -33,6 +35,8 @@ const defaultData = {
setAdminStatus(id: string, granted: boolean) {},
updateChallenge(challenge: ChallengeDto) {},
deleteChallenge(id: string) {},
+ updateAchievement(achievement: AchievementDto) {},
+ deleteAchievement(id: string) {},
updateEvent(event: EventDto) {},
deleteEvent(id: string) {},
updateOrganization(organization: OrganizationDto) {},
@@ -89,6 +93,12 @@ export function ServerDataProvider(props: { children: ReactNode }) {
deleteEvent(id: string) {
sock.updateEventData({ event: { id }, deleted: true });
},
+ updateAchievement(achievement: AchievementDto) {
+ sock.updateAchievementData({ achievement, deleted: false });
+ },
+ deleteAchievement(id: string) {
+ sock.updateAchievementData({ achievement: { id }, deleted: true });
+ },
deleteError(id: string) {
serverData.errors.delete(id);
setTimeout(() => setServerData({ ...serverData }), 0);
@@ -129,6 +139,18 @@ export function ServerDataProvider(props: { children: ReactNode }) {
/** Update defaultData object when ServerApi websocket receives a response */
useEffect(() => {
+ sock.onUpdateAchievementData((data) => {
+ if (data.deleted) {
+ serverData.achievements.delete(data.achievement.id);
+ } else {
+ serverData.achievements.set(
+ (data.achievement as AchievementDto).id,
+ data.achievement as AchievementDto
+ );
+ }
+
+ setTimeout(() => setServerData({ ...serverData }), 0);
+ });
sock.onUpdateEventData((data) => {
if (data.deleted) {
serverData.events.delete(data.event.id);
@@ -196,12 +218,25 @@ export function ServerDataProvider(props: { children: ReactNode }) {
(data.organization as OrganizationDto).id
)?.events ?? [];
+ const oldAchievements =
+ serverData.organizations.get(
+ (data.organization as OrganizationDto).id
+ )?.achivements ?? [];
+
sock.requestEventData({
events: (data.organization as OrganizationDto).events?.filter(
(ev: string) => !(ev in oldEvents)
),
});
+ if (data.organization.achivements) {
+ sock.requestAchievementData({
+ achievements: data.organization.achivements?.filter(
+ (achId) => !(achId in oldAchievements)
+ ),
+ });
+ }
+
serverData.organizations.set(
(data.organization as OrganizationDto).id,
data.organization as OrganizationDto
diff --git a/game/lib/achievements/achievement_cell.dart b/game/lib/achievements/achievement_cell.dart
index 2abd76e1..81fd17e5 100644
--- a/game/lib/achievements/achievement_cell.dart
+++ b/game/lib/achievements/achievement_cell.dart
@@ -1,3 +1,5 @@
+import 'dart:math';
+
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
@@ -46,9 +48,11 @@ class LoadingBar extends StatelessWidget {
),
Container(
height: 3,
- width: (totalTasks > 0 ? tasksFinished / totalTasks : 0) *
- constraints.maxWidth -
- 16,
+ width: max(
+ (totalTasks > 0 ? tasksFinished / totalTasks : 0) *
+ constraints.maxWidth -
+ 16,
+ 0),
margin: EdgeInsets.only(left: 8, top: 3),
alignment: Alignment.centerLeft,
decoration: new BoxDecoration(
@@ -143,7 +147,7 @@ class _AchievementCellState extends State {
Spacer(),
Align(
alignment: Alignment.bottomCenter,
- child: LoadingBar(3, 4)),
+ child: LoadingBar(this.tasksFinished, this.totalTasks)),
],
)
],
diff --git a/game/lib/achievements/achievements_page.dart b/game/lib/achievements/achievements_page.dart
index eb3f75ad..4aeece12 100644
--- a/game/lib/achievements/achievements_page.dart
+++ b/game/lib/achievements/achievements_page.dart
@@ -4,12 +4,14 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:game/achievements/achievement_cell.dart';
import 'package:game/api/game_api.dart';
import 'package:game/api/game_client_dto.dart';
+import 'package:game/model/achievement_model.dart';
import 'package:game/model/challenge_model.dart';
import 'package:game/model/event_model.dart';
import 'package:game/model/group_model.dart';
import 'package:game/utils/utility_functions.dart';
import 'package:game/model/tracker_model.dart';
import 'package:provider/provider.dart';
+import 'package:velocity_x/velocity_x.dart';
class AchievementCellDto {
AchievementCellDto({
@@ -48,8 +50,6 @@ class _AchievementsPageState extends State {
List selectedLocations = [];
String selectedDifficulty = '';
- List eventData = [];
-
@override
Widget build(BuildContext context) {
return Container(
@@ -72,124 +72,21 @@ class _AchievementsPageState extends State {
padding: EdgeInsets.all(30),
child: Column(
children: [
- Expanded(child: Consumer5(
- builder: (context,
- myEventModel,
- groupModel,
- trackerModel,
- challengeModel,
- apiClient,
- child) {
- if (myEventModel.searchResults == null) {
- myEventModel.searchEvents(
- 0,
- 1000,
- [
- EventTimeLimitationDto.PERPETUAL,
- EventTimeLimitationDto.LIMITED_TIME
- ],
- false,
- false,
- false);
- }
- final events = myEventModel.searchResults ?? [];
- if (!events.any((element) =>
- element.id == groupModel.curEventId)) {
- final curEvent = myEventModel
- .getEventById(groupModel.curEventId ?? "");
- if (curEvent != null) events.add(curEvent);
- }
- eventData.clear();
-
- for (EventDto event in events) {
- var tracker =
- trackerModel.trackerByEventId(event.id);
- var numberCompleted =
- tracker?.prevChallenges.length ?? 0;
- var complete =
- (numberCompleted == event.challenges?.length);
- var locationCount = event.challenges?.length ?? 0;
- DateTime now = DateTime.now();
- DateTime endtime =
- HttpDate.parse(event.endTime ?? "");
-
- Duration timeTillExpire = endtime.difference(now);
- if (locationCount != 1) continue;
- var challenge = challengeModel
- .getChallengeById(event.challenges?[0] ?? "");
-
- // print("Doing Event with now/endtime " + event.description.toString() + now.toString() + "/" + endtime.toString());
- if (challenge == null) {
- // print("Challenge is null for event " + event.description.toString());
-
- continue;
- }
- final challengeLocation =
- challenge.location?.name ?? "";
-
- bool eventMatchesDifficultySelection;
- bool eventMatchesCategorySelection;
- bool eventMatchesLocationSelection;
-
- if (selectedDifficulty.length == 0 ||
- selectedDifficulty == event.difficulty?.name)
- eventMatchesDifficultySelection = true;
- else
- eventMatchesDifficultySelection = false;
-
- if (selectedLocations.length > 0) {
- if (selectedLocations.contains(challengeLocation))
- eventMatchesLocationSelection = true;
- else
- eventMatchesLocationSelection = false;
- } else
- eventMatchesLocationSelection = true;
-
- if (selectedCategories.length > 0) {
- if (selectedCategories
- .contains(event.category?.name))
- eventMatchesCategorySelection = true;
- else
- eventMatchesCategorySelection = false;
- } else
- eventMatchesCategorySelection = true;
- if (!complete &&
- !timeTillExpire.isNegative &&
- eventMatchesDifficultySelection &&
- eventMatchesCategorySelection &&
- eventMatchesLocationSelection) {
- eventData.add(AchievementCellDto(
- location:
- friendlyLocation[challenge.location] ?? "",
- name: event.name ?? "",
- lat: challenge.latF ?? null,
- long: challenge.longF ?? null,
- thumbnail: SvgPicture.asset(
- "assets/icons/achievementsilver.svg"),
- complete: complete,
- description: event.description ?? "",
- difficulty:
- friendlyDifficulty[event.difficulty] ?? "",
- points: challenge.points ?? 0,
- eventId: event.id,
- ));
- } else if (event.id == groupModel.curEventId) {
- apiClient.serverApi?.setCurrentEvent(
- SetCurrentEventDto(eventId: ""));
- }
- }
+ Expanded(child: Consumer2(
+ builder: (context, achModel, apiClient, child) {
+ final achList = achModel.getAvailableTrackerPairs();
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 3),
- itemCount: eventData.length,
+ itemCount: achList.length,
itemBuilder: (context, index) {
return AchievementCell(
key: UniqueKey(),
- eventData[index].description,
- eventData[index].thumbnail,
- 3,
- 4);
+ achList[index].$2.description ?? "",
+ SvgPicture.asset(
+ "assets/icons/achievementsilver.svg"),
+ achList[index].$1.progress,
+ achList[index].$2.requiredPoints ?? 0);
},
physics: BouncingScrollPhysics(),
separatorBuilder: (context, index) {
diff --git a/game/lib/api/game_client_dto.dart b/game/lib/api/game_client_dto.dart
index 940de9f1..5a0b3984 100644
--- a/game/lib/api/game_client_dto.dart
+++ b/game/lib/api/game_client_dto.dart
@@ -211,7 +211,7 @@ class AchievementTrackerDto {
class UpdateAchievementDataDto {
Map toJson() {
Map fields = {};
- fields['achievement'] = achievement.toJson();
+ fields['achievement'] = achievement!.toJson();
fields['deleted'] = deleted;
return fields;
}
@@ -257,6 +257,33 @@ class RequestAchievementDataDto {
late List achievements;
}
+class RequestAchievementTrackerDataDto {
+ Map toJson() {
+ Map fields = {};
+ if (achievements != null) {
+ fields['achievements'] = achievements;
+ }
+ return fields;
+ }
+
+ RequestAchievementTrackerDataDto.fromJson(Map fields) {
+ achievements = fields.containsKey('achievements')
+ ? (List.from(fields['achievements']))
+ : null;
+ }
+
+ void partialUpdate(RequestAchievementTrackerDataDto other) {
+ achievements =
+ other.achievements == null ? achievements : other.achievements;
+ }
+
+ RequestAchievementTrackerDataDto({
+ this.achievements,
+ });
+
+ late List? achievements;
+}
+
class LoginDto {
Map toJson() {
Map fields = {};
@@ -286,7 +313,7 @@ class LoginDto {
if (aud != null) {
fields['aud'] = aud!.name;
}
- fields['enrollmentType'] = enrollmentType.name;
+ fields['enrollmentType'] = enrollmentType!.name;
return fields;
}
@@ -373,23 +400,14 @@ class RefreshTokenDto {
class CompletedChallengeDto {
Map toJson() {
Map fields = {};
- fields['challengeId'] = challengeId;
return fields;
}
- CompletedChallengeDto.fromJson(Map fields) {
- challengeId = fields["challengeId"];
- }
-
- void partialUpdate(CompletedChallengeDto other) {
- challengeId = other.challengeId;
- }
+ CompletedChallengeDto.fromJson(Map fields) {}
- CompletedChallengeDto({
- required this.challengeId,
- });
+ void partialUpdate(CompletedChallengeDto other) {}
- late String challengeId;
+ CompletedChallengeDto();
}
class ChallengeDto {
@@ -520,7 +538,7 @@ class RequestChallengeDataDto {
class UpdateChallengeDataDto {
Map toJson() {
Map fields = {};
- fields['challenge'] = challenge.toJson();
+ fields['challenge'] = challenge!.toJson();
fields['deleted'] = deleted;
return fields;
}
@@ -751,7 +769,7 @@ class UpdateLeaderDataDto {
fields['eventId'] = eventId;
}
fields['offset'] = offset;
- fields['users'] = users
+ fields['users'] = users!
.map