Updated phone UI

This commit is contained in:
2026-06-03 21:42:24 +03:00
parent 07c323752a
commit f2fcd42edf
21 changed files with 531 additions and 17 deletions
@@ -36,6 +36,12 @@ public:
UFUNCTION(BlueprintPure)
const TArray<UCommission*>& GetAcceptedCommissions() const { return AcceptedCommissions; }
// Commissions completed within the current period. Cleared at the day-roll alongside the accepted
// list (see ExpireAccepted), so today this is "completed today" for both tiers — true week-long
// retention for weeklies needs the deferred §13.1 weekly-lifecycle work.
UFUNCTION(BlueprintPure)
const TArray<UCommission*>& GetCompletedCommissions() const { return CompletedCommissions; }
UFUNCTION(BlueprintCallable)
void AcceptCommission(UCommission* Commission);
@@ -0,0 +1,40 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "ForumAppWidget.h"
#include "Components/Button.h"
#include "Components/WidgetSwitcher.h"
namespace
{
// Switcher page order — must match the child order authored in the BP.
constexpr int32 CommissionsTabIndex = 0;
constexpr int32 ProfileTabIndex = 1;
}
void UForumAppWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (CommissionsTabButton)
CommissionsTabButton->OnClicked.AddUniqueDynamic(this, &UForumAppWidget::ShowCommissions);
if (ProfileTabButton)
ProfileTabButton->OnClicked.AddUniqueDynamic(this, &UForumAppWidget::ShowProfile);
// Open on the commission board — it is the forum's primary loop surface (§13).
ShowCommissions();
}
void UForumAppWidget::ShowCommissions()
{
if (TabSwitcher)
TabSwitcher->SetActiveWidgetIndex(CommissionsTabIndex);
}
void UForumAppWidget::ShowProfile()
{
if (TabSwitcher)
TabSwitcher->SetActiveWidgetIndex(ProfileTabIndex);
}
@@ -0,0 +1,39 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "NakedDesire/UI/Phone/PhoneAppWidget.h"
#include "ForumAppWidget.generated.h"
class UButton;
class UWidgetSwitcher;
// The forum app (GDD §13). Hosts the two bottom tabs the forum surface is scoped to — the commission
// board and the player's own profile (§13.3) — as pages of a switcher. Page content lives in BP:
// the Commissions page is a UForumCommissionsWidget; the Profile page is a placeholder until the
// profile / followers systems land (Phase 8). This shell only drives tab selection.
UCLASS()
class NAKEDDESIRE_API UForumAppWidget : public UPhoneAppWidget
{
GENERATED_BODY()
protected:
virtual void NativeOnInitialized() override;
private:
UPROPERTY(meta = (BindWidget))
TObjectPtr<UWidgetSwitcher> TabSwitcher;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> CommissionsTabButton;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> ProfileTabButton;
UFUNCTION()
void ShowCommissions();
UFUNCTION()
void ShowProfile();
};
@@ -0,0 +1,126 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "ForumCommissionWidget.h"
#include "Components/Button.h"
#include "CommonTextBlock.h"
#include "NakedDesire/Commissions/Commission.h"
#include "NakedDesire/Commissions/CommissionObjective.h"
#include "NakedDesire/Commissions/CommissionTypes.h"
#define LOCTEXT_NAMESPACE "ForumCommission"
void UForumCommissionWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (AcceptButton)
AcceptButton->OnClicked.AddUniqueDynamic(this, &UForumCommissionWidget::HandleAcceptClicked);
if (AbandonButton)
AbandonButton->OnClicked.AddUniqueDynamic(this, &UForumCommissionWidget::HandleAbandonClicked);
}
void UForumCommissionWidget::SetCommission(UCommission* InCommission)
{
Commission = InCommission;
CachedObjectivesString.Reset(); // force a fresh push for the (possibly reused) row
TimeSinceProgressRefresh = 0.0f;
if (!Commission)
return;
if (TitleText)
TitleText->SetText(Commission->GetTitle());
if (PosterText)
PosterText->SetText(FText::Format(LOCTEXT("PosterFmt", "by {0}"), Commission->GetPosterUsername()));
if (RewardText)
RewardText->SetText(FormatReward(Commission->GetReward()));
UpdateObjectivesText();
// Accept only offers; abandon only commitments. Completed / expired rows show neither control.
const ECommissionState State = Commission->GetState();
if (AcceptButton)
AcceptButton->SetVisibility(State == ECommissionState::Offered ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
if (AbandonButton)
AbandonButton->SetVisibility(State == ECommissionState::Accepted ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
}
void UForumCommissionWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
// Only accepted rows have live progress; offered / completed / expired rows are static.
if (!Commission || Commission->GetState() != ECommissionState::Accepted)
return;
TimeSinceProgressRefresh += InDeltaTime;
if (TimeSinceProgressRefresh < ProgressRefreshInterval)
return;
TimeSinceProgressRefresh = 0.0f;
UpdateObjectivesText();
}
void UForumCommissionWidget::HandleAcceptClicked()
{
if (Commission)
OnAcceptClicked.ExecuteIfBound(Commission);
}
void UForumCommissionWidget::HandleAbandonClicked()
{
if (Commission)
OnAbandonClicked.ExecuteIfBound(Commission);
}
void UForumCommissionWidget::UpdateObjectivesText()
{
if (!DescriptionText)
return;
FString NewText = BuildObjectivesString();
if (NewText == CachedObjectivesString)
return; // nothing changed since the last refresh — skip the SetText churn
CachedObjectivesString = MoveTemp(NewText);
DescriptionText->SetText(FText::FromString(CachedObjectivesString));
}
FString UForumCommissionWidget::BuildObjectivesString() const
{
FString Result;
if (!Commission)
return Result;
for (const UCommissionObjective* Objective : Commission->GetObjectives())
{
if (!Objective)
continue;
if (!Result.IsEmpty())
Result += LINE_TERMINATOR;
const int32 Pct = FMath::RoundToInt(Objective->GetProgress() * 100.0f);
Result += FString::Printf(TEXT("• %s (%d%%)"), *Objective->GetDescription().ToString(), Pct);
}
return Result;
}
FText UForumCommissionWidget::FormatReward(const FCommissionReward& Reward)
{
TArray<FString> Parts;
if (Reward.Money != 0)
Parts.Add(FString::Printf(TEXT("¥%d"), Reward.Money)); // ¥ = yen sign
if (!FMath::IsNearlyZero(Reward.XP))
Parts.Add(FString::Printf(TEXT("%.0f XP"), Reward.XP));
if (Reward.Followers != 0)
Parts.Add(FString::Printf(TEXT("+%d followers"), Reward.Followers));
return FText::FromString(FString::Join(Parts, TEXT(" ")));
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,78 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "CommonUserWidget.h"
#include "ForumCommissionWidget.generated.h"
class UButton;
class UCommonTextBlock;
class UCommission;
struct FCommissionReward;
// One commission entry on the forum board (GDD §13). Populated from a runtime UCommission by the
// owning UForumCommissionsWidget; it shows the title, lore poster, reward, and per-objective progress,
// and surfaces the accept / abandon control appropriate to the commission's lifecycle state. The row
// owns no logic beyond display — it reports button taps up via delegates, like UPhoneAppIconWidget.
UCLASS(Abstract)
class NAKEDDESIRE_API UForumCommissionWidget : public UCommonUserWidget
{
GENERATED_BODY()
public:
// Bind a runtime commission and refresh the row. Passing null leaves the row blank.
void SetCommission(UCommission* InCommission);
DECLARE_DELEGATE_OneParam(FOnCommissionActionRequested, UCommission*);
FOnCommissionActionRequested OnAcceptClicked; // Offered -> accept
FOnCommissionActionRequested OnAbandonClicked; // Accepted -> abandon (§13.2, no penalty)
protected:
virtual void NativeOnInitialized() override;
// Accepted commissions ramp progress continuously (travel distance, hold timers) with no per-tick
// delegate, so the row polls its objectives here while live to keep the percentages current.
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCommonTextBlock> TitleText;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UCommonTextBlock> PosterText;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UCommonTextBlock> RewardText;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCommonTextBlock> DescriptionText;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> AcceptButton;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> AbandonButton;
UFUNCTION()
void HandleAcceptClicked();
UFUNCTION()
void HandleAbandonClicked();
// Rebuild the per-objective progress text, but only push it to the widget when it actually changed,
// so the poll doesn't churn SetText every tick.
void UpdateObjectivesText();
FString BuildObjectivesString() const;
static FText FormatReward(const FCommissionReward& Reward);
UPROPERTY()
TObjectPtr<UCommission> Commission;
// Throttle for the in-progress poll (seconds between objective-text refreshes) and its accumulator.
static constexpr float ProgressRefreshInterval = 0.25f;
float TimeSinceProgressRefresh = 0.0f;
// Last string pushed to DescriptionText; used to skip redundant SetText calls.
FString CachedObjectivesString;
};
@@ -0,0 +1,103 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "ForumCommissionsWidget.h"
#include "ForumCommissionWidget.h"
#include "Components/PanelWidget.h"
#include "NakedDesire/Commissions/Commission.h"
#include "NakedDesire/Commissions/MissionSubsystem.h"
void UForumCommissionsWidget::NativeConstruct()
{
Super::NativeConstruct();
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->OnBoardChanged.AddUniqueDynamic(this, &UForumCommissionsWidget::Rebuild);
Rebuild();
}
void UForumCommissionsWidget::NativeDestruct()
{
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->OnBoardChanged.RemoveDynamic(this, &UForumCommissionsWidget::Rebuild);
Super::NativeDestruct();
}
void UForumCommissionsWidget::Rebuild()
{
UMissionSubsystem* Missions = GetMissionSubsystem();
if (!Missions || !CommissionEntryClass)
return;
PopulateOffered(DailyContainer, ECommissionTier::Daily);
PopulateOffered(WeeklyContainer, ECommissionTier::Weekly);
PopulateContainer(AcceptedContainer, Missions->GetAcceptedCommissions());
PopulateContainer(CompletedContainer, Missions->GetCompletedCommissions());
}
void UForumCommissionsWidget::PopulateOffered(UPanelWidget* Container, ECommissionTier Tier)
{
if (!Container)
return;
Container->ClearChildren();
UMissionSubsystem* Missions = GetMissionSubsystem();
if (!Missions)
return;
for (UCommission* Commission : Missions->GetOfferedCommissions())
{
if (Commission && Commission->GetTier() == Tier)
AddEntry(Container, Commission);
}
}
void UForumCommissionsWidget::PopulateContainer(UPanelWidget* Container, const TArray<UCommission*>& Commissions)
{
if (!Container)
return;
Container->ClearChildren();
for (UCommission* Commission : Commissions)
{
if (Commission)
AddEntry(Container, Commission);
}
}
UForumCommissionWidget* UForumCommissionsWidget::AddEntry(UPanelWidget* Container, UCommission* Commission)
{
UForumCommissionWidget* Entry = CreateWidget<UForumCommissionWidget>(this, CommissionEntryClass);
if (!Entry)
return nullptr;
Entry->SetCommission(Commission);
Entry->OnAcceptClicked.BindUObject(this, &UForumCommissionsWidget::HandleAcceptClicked);
Entry->OnAbandonClicked.BindUObject(this, &UForumCommissionsWidget::HandleAbandonClicked);
Container->AddChild(Entry);
return Entry;
}
void UForumCommissionsWidget::HandleAcceptClicked(UCommission* Commission)
{
// AcceptCommission broadcasts OnBoardChanged, which drives Rebuild — no manual refresh here.
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->AcceptCommission(Commission);
}
void UForumCommissionsWidget::HandleAbandonClicked(UCommission* Commission)
{
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->AbandonCommission(Commission);
}
UMissionSubsystem* UForumCommissionsWidget::GetMissionSubsystem() const
{
const UWorld* World = GetWorld();
return World ? World->GetSubsystem<UMissionSubsystem>() : nullptr;
}
@@ -0,0 +1,58 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "CommonUserWidget.h"
#include "NakedDesire/Commissions/CommissionTypes.h"
#include "ForumCommissionsWidget.generated.h"
class UPanelWidget;
class UForumCommissionWidget;
class UMissionSubsystem;
class UCommission;
// The forum's Commissions tab (GDD §13). Reads the live board from UMissionSubsystem and lays the
// commissions out into four sections: offered dailies, offered weeklies, accepted (in-progress, both
// tiers), and completed-this-period. Rebuilds whenever the board changes, and routes row accept /
// abandon taps back to the subsystem. Section containers + the row class are authored in BP (§17.5).
UCLASS(Abstract)
class NAKEDDESIRE_API UForumCommissionsWidget : public UCommonUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
private:
// Offered commissions, split by tier.
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> DailyContainer;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> WeeklyContainer;
// Accepted commitments (both tiers) and completions for the current period.
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> AcceptedContainer;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> CompletedContainer;
UPROPERTY(EditDefaultsOnly, Category = "Forum")
TSubclassOf<UForumCommissionWidget> CommissionEntryClass;
// Bound to UMissionSubsystem::OnBoardChanged (a dynamic delegate, hence UFUNCTION).
UFUNCTION()
void Rebuild();
void PopulateOffered(UPanelWidget* Container, ECommissionTier Tier);
void PopulateContainer(UPanelWidget* Container, const TArray<UCommission*>& Commissions);
UForumCommissionWidget* AddEntry(UPanelWidget* Container, UCommission* Commission);
void HandleAcceptClicked(UCommission* Commission);
void HandleAbandonClicked(UCommission* Commission);
UMissionSubsystem* GetMissionSubsystem() const;
};
@@ -0,0 +1,28 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "PhoneStatusBar.h"
#include "CommonTextBlock.h"
#include "NakedDesire/Global/TimeOfDaySubsystem.h"
void UPhoneStatusBar::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
if (!TimeText)
return;
const UWorld* World = GetWorld();
const UTimeOfDaySubsystem* Time = World ? World->GetSubsystem<UTimeOfDaySubsystem>() : nullptr;
if (!Time)
return;
// 24-hour HH:MM, refreshed only when the minute rolls so we aren't re-laying-out text every frame.
FString NewTime = FString::Printf(TEXT("%02d:%02d"), Time->GetHour(), Time->GetMinute());
if (NewTime == CachedTimeString)
return;
CachedTimeString = MoveTemp(NewTime);
TimeText->SetText(FText::FromString(CachedTimeString));
}
@@ -0,0 +1,26 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "CommonUserWidget.h"
#include "PhoneStatusBar.generated.h"
class UCommonTextBlock;
UCLASS()
class NAKEDDESIRE_API UPhoneStatusBar : public UCommonUserWidget
{
GENERATED_BODY()
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCommonTextBlock> TimeText;
protected:
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
// Last HH:MM string pushed to TimeText; the tick re-reads the clock each frame but only
// touches the widget when the displayed minute actually changes.
FString CachedTimeString;
};