Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions app/Devnull/Data/Achievements.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
namespace App\Devnull\Data;

/**
* A profile's achievements as the web's shelf shows them: the Unix rank and
* its level, the XP towards the next rank, and the badges, unlocked or not.
* A profile's achievements as the web's shelf and dialog show them: the Unix
* rank and its level, the XP towards the next rank, every rank, and the
* badges, unlocked or not.
*/
final readonly class Achievements
{
/**
* @param list<array{rank: string, level: int, min_xp: int}> $ranks
* @param list<Rank> $ranks
* @param list<Badge> $badges
*/
public function __construct(
Expand All @@ -31,21 +32,13 @@ public static function fromArray(mixed $data): ?self
return null;
}

$ranks = [];

foreach (is_array($data['ranks'] ?? null) ? $data['ranks'] : [] as $rank) {
if (is_array($rank) && is_string($rank['rank'] ?? null) && is_int($rank['level'] ?? null) && is_int($rank['min_xp'] ?? null)) {
$ranks[] = ['rank' => $rank['rank'], 'level' => $rank['level'], 'min_xp' => $rank['min_xp']];
}
}

return new self(
level: $data['level'],
rank: $data['rank'],
xp: is_int($data['xp'] ?? null) ? max(0, $data['xp']) : 0,
nextLevelXp: is_int($data['next_level_xp'] ?? null) ? $data['next_level_xp'] : null,
levelProgress: is_int($data['level_progress'] ?? null) ? max(0, min(100, $data['level_progress'])) : 0,
ranks: $ranks,
ranks: array_values(array_filter(array_map(Rank::fromArray(...), is_array($data['ranks'] ?? null) ? $data['ranks'] : []))),
badges: array_values(array_map(Badge::fromArray(...), array_filter(is_array($data['badges'] ?? null) ? $data['badges'] : [], is_array(...)))),
);
}
Expand Down Expand Up @@ -75,13 +68,37 @@ public function xpLabel(): string
* "130 XP to operator", or the web's line for the top rank.
*/
public function nextRankLabel(): string
{
$next = $this->nextRank();

return $next === null ? 'Maximum rank (UID 0)' : ($next->minXp - $this->xp).' XP to '.$next->rank;
}

/**
* The rank a level above this one, as the web finds it, or null at the top.
*/
public function nextRank(): ?Rank
{
foreach ($this->ranks as $rank) {
if ($rank['level'] === $this->level + 1) {
return ($rank['min_xp'] - $this->xp).' XP to '.$rank['rank'];
if ($rank->level === $this->level + 1) {
return $rank;
}
}

return null;
}

/**
* Where the badge with this id sits on the shelf, or null when there's none.
*/
public function badgeIndex(string $id): ?int
{
foreach ($this->badges as $index => $badge) {
if ($badge->id === $id) {
return $index;
}
}

return 'Maximum rank (UID 0)';
return null;
}
}
16 changes: 16 additions & 0 deletions app/Devnull/Data/Badge.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,20 @@ public function showsProgress(): bool
{
return ! $this->unlocked && $this->target > 1;
}

/**
* "unlocked", or how far along it is: "3 of 10".
*/
public function statusLabel(): string
{
return $this->unlocked ? 'unlocked' : "{$this->progress} of {$this->target}";
}

/**
* How far along it is as a whole percentage, for the bar under a locked badge in the dialog.
*/
public function progressPercent(): int
{
return min(100, (int) round($this->progress / $this->target * 100));
}
}
36 changes: 36 additions & 0 deletions app/Devnull/Data/Rank.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace App\Devnull\Data;

/**
* One of the Unix ranks a profile climbs through by XP, as the web's
* achievements dialog lists them.
*/
final readonly class Rank
{
public function __construct(
public string $rank,
public int $level,
public int $minXp = 0,
public string $description = '',
public bool $achieved = false,
) {}

/**
* Reads one of the profile's `achievements.ranks`, or null when it isn't one.
*/
public static function fromArray(mixed $data): ?self
{
if (! is_array($data) || ! is_string($data['rank'] ?? null) || ! is_int($data['level'] ?? null) || ! is_int($data['min_xp'] ?? null)) {
return null;
}

return new self(
rank: $data['rank'],
level: $data['level'],
minXp: $data['min_xp'],
description: is_string($data['description'] ?? null) ? $data['description'] : '',
achieved: ($data['achieved'] ?? false) === true,
);
}
}
65 changes: 65 additions & 0 deletions app/NativeComponents/Concerns/OpensAchievements.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace App\NativeComponents\Concerns;

/**
* The web's achievements dialog, as a sheet over a profile (see
* partials/achievements-sheet). Tapping the shelf opens it on the badges;
* tapping a badge opens it with that badge marked in the owner's lane.
*
* Like the web, it has two tabs, the badges and the Unix ranks. Each opening
* starts on the badges, and the sheet forgets the badge and the tab once iOS
* says it's gone.
*/
trait OpensAchievements
{
public const array AchievementsTabs = ['badges', 'ranks'];

/**
* Whether the sheet is up, from the tap until iOS says it's gone.
*/
public bool $achievementsOpen = false;

/**
* The id of the badge the sheet was opened on, if it was.
*/
public ?string $achievementsBadge = null;

/**
* 'badges' or 'ranks'.
*/
public string $achievementsTab = 'badges';

/**
* The shelf, or one of its badges.
*/
public function openAchievements(?string $badge = null): void
{
$achievements = $this->profile?->achievements;

if ($achievements === null) {
return;
}

$this->achievementsBadge = $badge !== null && $achievements->badgeIndex($badge) !== null ? $badge : null;
$this->achievementsTab = 'badges';
$this->achievementsOpen = true;
}

public function showAchievementsTab(string $tab): void
{
if (in_array($tab, self::AchievementsTabs, true)) {
$this->achievementsTab = $tab;
}
}

/**
* iOS says the sheet has gone.
*/
public function achievementsClosed(): void
{
$this->achievementsOpen = false;
$this->achievementsBadge = null;
$this->achievementsTab = 'badges';
}
}
2 changes: 2 additions & 0 deletions app/NativeComponents/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use App\Icons\Lucide;
use App\NativeComponents\Concerns\ExpandsThreads;
use App\NativeComponents\Concerns\HoldsNewCommits;
use App\NativeComponents\Concerns\OpensAchievements;
use App\NativeComponents\Concerns\OpensInTab;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\ShowsCommits;
Expand Down Expand Up @@ -44,6 +45,7 @@ class Profile extends NativeComponent
{
use ExpandsThreads;
use HoldsNewCommits;
use OpensAchievements;
use OpensInTab {
isShowing as isShowingPath;
}
Expand Down
124 changes: 124 additions & 0 deletions resources/views/native/partials/achievements-sheet.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
{{--
The web's achievements dialog, as a sheet over the profile (see OpensAchievements): the title with the rank in the
owner's lane, the handle, XP and how many badges are unlocked, the bar to the next rank, then two tabs. Badges
lists every badge with its icon, name and description, a tick once it's unlocked or its progress until then, and a
bar under a locked one that takes more than one step. Unix Ranks lists the ranks with the XP each needs, the
current one in the lane. The XP formula closes it, as on the web.

A badge the sheet was opened on wears the lane, as the web marks the current rank. EDGE can't scroll to a child,
so a badge in the second half of the list opens the sheet at its end instead, where that badge is in view.

The web's locked cards are dashed. A dashed card costs about ninety nodes here (see dashed-box), so a locked card
is a faint wash inside a solid hairline, as the shelf's chips are. Only the tab showing is drawn, and nothing at
all until the sheet opens, to keep the profile's frames small.
--}}
@php($achievements = $profile?->achievements)
<native:bottom-sheet ref="achievements-sheet" :visible="$achievementsOpen && $achievements !== null" detents="large" @dismiss="achievementsClosed">
@if ($achievementsOpen && $achievements)
@php($lane = $profile->user->lane())
@php($markedIndex = $achievementsBadge === null ? null : $achievements->badgeIndex($achievementsBadge))
@php($opensAtEnd = $achievementsTab === 'badges' && $markedIndex !== null && $markedIndex >= intdiv(count($achievements->badges), 2))
<native:scroll-view ref="achievements-scroll" native:key="achievements-{{ $achievementsTab }}" scroll-anchor="{{ $opensAtEnd ? 'bottom' : 'top' }}" class="w-full flex-1">
<native:column class="w-full px-5 pt-6 pb-10 gap-4">
<native:column class="w-full gap-[6]">
<native:row class="w-full gap-2 items-center">
<native:text font="semibold" class="flex-1 text-base text-theme-ink" :max-lines="1">Developer Achievements</native:text>
<native:column ref="sheet-rank" class="px-2 py-[2] rounded-full border border-theme-lane-{{ $lane }}/25 bg-theme-lane-{{ $lane }}/10">
<native:text font="mono-medium" class="text-xs text-theme-lane-{{ $lane }}">{{ $achievements->label() }}</native:text>
</native:column>
</native:row>
<native:text ref="sheet-summary" font="mono" class="w-full text-xs text-theme-ink-muted">{{ $profile->user->handle() }} · {{ $achievements->xp }} XP · {{ $achievements->unlockedCount() }}/{{ count($achievements->badges) }} badges unlocked</native:text>
</native:column>

<native:column class="w-full p-3 gap-2 rounded-lg border border-theme-hairline bg-theme-wash/30">
<native:row class="w-full items-center">
<native:text ref="sheet-xp" font="mono" class="text-xs text-theme-ink-muted">{{ $achievements->xpLabel() }}</native:text>
<native:spacer />
<native:text ref="sheet-next-rank" font="mono" class="text-xs text-theme-ink-muted">{{ $achievements->nextRankLabel() }}</native:text>
</native:row>
@include('native.partials.progress-bar', ['ref' => 'sheet-level-progress', 'percent' => $achievements->levelProgress, 'fill' => "bg-theme-lane-{$lane}", 'label' => "{$achievements->levelProgress}% of the way to the next rank"])
</native:column>

<native:column class="w-full">
<native:row class="w-full">
@foreach ([['badges', 'Badges', count($achievements->badges)], ['ranks', 'Unix Ranks', count($achievements->ranks)]] as [$key, $title, $count])
<native:pressable ref="achievements-tab-{{ $key }}" @press="showAchievementsTab('{{ $key }}')" a11y-label="{{ $title }}, {{ $count }}{{ $achievementsTab === $key ? ', selected' : '' }}">
<native:column>
<native:column class="px-3 py-[6]">
<native:text font="mono-medium" class="text-xs {{ $achievementsTab === $key ? 'text-theme-ink' : 'text-theme-ink-muted' }}">{{ $title }} ({{ $count }})</native:text>
</native:column>
<native:column class="w-full h-[2] {{ $achievementsTab === $key ? 'bg-theme-lane-'.$lane : 'bg-transparent' }}" />
</native:column>
</native:pressable>
@endforeach
</native:row>
<native:column class="w-full h-[1] bg-theme-hairline" />
</native:column>

@if ($achievementsTab === 'badges')
<native:column ref="sheet-badges" class="w-full gap-2">
@foreach ($achievements->badges as $index => $badge)
@php($icon = \App\Icons\Lucide::forBadge($badge->icon))
<native:column ref="sheet-badge-{{ $badge->id }}" class="w-full p-3 gap-[6] rounded-lg {{ $index === $markedIndex ? 'border-2 border-theme-lane-'.$lane : 'border border-theme-hairline' }} {{ $badge->unlocked ? 'bg-theme-surface' : 'bg-theme-wash/20' }}" a11y-label="{{ $badge->name }}, {{ $badge->statusLabel() }}. {{ $badge->description }}">
<native:row class="w-full gap-2 items-center">
<native:column :width="24" :height="24" class="items-center justify-center rounded border bg-theme-wash {{ $badge->unlocked ? 'border-theme-hairline' : 'border-theme-hairline/60' }}">
<native:icon :ios="$icon->ios()" :android="$icon->android()" :size="14" class="{{ $badge->unlocked ? 'text-theme-ink' : 'text-theme-ink-muted/60' }}" />
</native:column>
<native:text font="mono-medium" class="flex-1 text-xs text-theme-ink" :max-lines="1">{{ $badge->name }}</native:text>
@if ($badge->unlocked)
<native:column class="px-[6] py-[2] rounded bg-emerald-500/10">
<native:text font="mono-medium" class="text-[10px] text-emerald-600 dark:text-emerald-400">✓</native:text>
</native:column>
@else
<native:text font="mono-medium" class="text-[10px] text-theme-ink-muted">{{ $badge->progress }}/{{ $badge->target }}</native:text>
@endif
</native:row>
<native:text class="w-full text-xs leading-normal text-theme-ink-muted">{{ $badge->description }}</native:text>
@if ($badge->showsProgress())
<native:column class="w-full pt-1">
@include('native.partials.progress-bar', ['ref' => "sheet-badge-progress-{$badge->id}", 'percent' => $badge->progressPercent(), 'height' => 4, 'label' => "{$badge->progressPercent()}% of the way"])
</native:column>
@endif
</native:column>
@endforeach
</native:column>
@else
<native:column ref="sheet-ranks" class="w-full gap-2">
@foreach ($achievements->ranks as $rank)
@php($current = $rank->level === $achievements->level)
<native:row ref="sheet-rank-{{ $rank->level }}" class="w-full p-3 gap-3 items-center rounded-lg {{ $current ? 'border-2 border-theme-lane-'.$lane.' bg-theme-surface' : ($rank->achieved ? 'border border-theme-hairline bg-theme-surface' : 'border border-theme-hairline/60 bg-theme-wash/20 opacity-70') }}" a11y-label="Level {{ $rank->level }}, {{ $rank->rank }}{{ $current ? ', current' : '' }}. {{ $rank->description }}. {{ $rank->achieved ? 'Achieved' : 'Requires '.$rank->minXp.' XP' }}">
<native:column :width="24" :height="24" class="items-center justify-center rounded {{ $current ? 'bg-theme-lane-'.$lane : 'bg-theme-wash' }}">
<native:text font="mono-medium" class="text-xs {{ $current ? 'text-theme-surface' : ($rank->achieved ? 'text-theme-ink' : 'text-theme-ink-muted') }}">{{ $rank->level }}</native:text>
</native:column>
<native:column class="flex-1 gap-[2]">
<native:row class="gap-2 items-center">
<native:text font="mono-medium" class="text-xs text-theme-ink">{{ $rank->rank }}</native:text>
@if ($current)
<native:column class="px-[6] rounded bg-theme-lane-{{ $lane }}/15">
<native:text font="mono-medium" class="text-[10px] text-theme-lane-{{ $lane }}">CURRENT</native:text>
</native:column>
@endif
</native:row>
<native:text class="w-full text-[11px] text-theme-ink-muted">{{ $rank->description }}</native:text>
</native:column>
<native:column class="items-end gap-[2]">
<native:text font="mono-medium" class="text-xs text-theme-ink">{{ $rank->minXp }} XP</native:text>
@if ($rank->achieved)
<native:text font="mono" class="text-[10px] text-emerald-600 dark:text-emerald-400">✓ Achieved</native:text>
@else
<native:text font="mono" class="text-[10px] text-theme-ink-muted">Requires {{ $rank->minXp }} XP</native:text>
@endif
</native:column>
</native:row>
@endforeach
</native:column>
@endif

<native:column class="w-full pt-2 gap-2">
<native:column class="w-full h-[1] bg-theme-hairline" />
<native:text font="mono" class="w-full text-center text-[11px] text-theme-ink-muted/80">XP: 10/commit · 15/day streak · 5/star received · 5/reply · 2/star given</native:text>
</native:column>
</native:column>
</native:scroll-view>
@endif
</native:bottom-sheet>
Loading
Loading