@tastic/hud 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/chrome/index.d.mts +4 -2
- package/dist/chrome/index.d.ts +4 -2
- package/dist/chrome/index.js +35 -21
- package/dist/chrome/index.mjs +1 -1
- package/dist/{chunk-HGJKNLMD.mjs → chunk-UOA35OWJ.mjs} +28 -14
- package/dist/index.d.mts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +135 -111
- package/dist/index.mjs +61 -52
- package/package.json +10 -10
- package/src/AchievementCatalogSection.tsx +9 -2
- package/src/AchievementRow.tsx +4 -4
- package/src/BaseSettingsDialog.tsx +3 -1
- package/src/BaseStatsScreen.tsx +17 -7
- package/src/ConfirmDialog.tsx +25 -2
- package/src/PlayerSetupPanel.tsx +77 -56
- package/src/index.ts +1 -1
package/dist/index.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
UpdateDialog,
|
|
20
20
|
getStaggeredWordDuration,
|
|
21
21
|
useCompactDialog
|
|
22
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-UOA35OWJ.mjs";
|
|
23
23
|
import {
|
|
24
24
|
COMPACT_DIALOG_HEIGHT,
|
|
25
25
|
DialogHeader,
|
|
@@ -683,7 +683,7 @@ function loadSkiaWeb() {
|
|
|
683
683
|
}
|
|
684
684
|
|
|
685
685
|
// src/PlayerSetupPanel.tsx
|
|
686
|
-
import { ProfilePicker } from "@tastic/profile";
|
|
686
|
+
import { getOptionPickerContentSize, getProfilePickerContentSize, OptionPicker, ProfilePicker } from "@tastic/profile";
|
|
687
687
|
import { StyleSheet as StyleSheet8, View as View8 } from "react-native";
|
|
688
688
|
import { Text as Text6 } from "react-native-paper";
|
|
689
689
|
|
|
@@ -721,26 +721,73 @@ function usePopoverHost() {
|
|
|
721
721
|
return { openId, toggle, close };
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
+
// src/useZoneClampedAlign.ts
|
|
725
|
+
import { useZoneBounds } from "@tastic/split-screen";
|
|
726
|
+
import { useEffect as useEffect2, useState as useState3 } from "react";
|
|
727
|
+
import { useWindowDimensions as useWindowDimensions3 } from "react-native";
|
|
728
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
729
|
+
var EDGE_MARGIN2 = 12;
|
|
730
|
+
function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
|
|
731
|
+
const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
|
|
732
|
+
const zone = useZoneBounds();
|
|
733
|
+
const { height: windowHeight } = useWindowDimensions3();
|
|
734
|
+
const insets = useSafeAreaInsets();
|
|
735
|
+
const shouldMeasure = open && auto.measured && zone !== null;
|
|
736
|
+
const [ownBounds, setOwnBounds] = useState3(null);
|
|
737
|
+
const [prevShouldMeasure, setPrevShouldMeasure] = useState3(shouldMeasure);
|
|
738
|
+
if (shouldMeasure !== prevShouldMeasure) {
|
|
739
|
+
setPrevShouldMeasure(shouldMeasure);
|
|
740
|
+
setOwnBounds(null);
|
|
741
|
+
}
|
|
742
|
+
useEffect2(() => {
|
|
743
|
+
if (!shouldMeasure) return;
|
|
744
|
+
auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
|
|
745
|
+
const belowShared = zone.zoneSide === "belowShared";
|
|
746
|
+
const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
|
|
747
|
+
const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
|
|
748
|
+
const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
|
|
749
|
+
let awaySide = belowShared ? "below" : "above";
|
|
750
|
+
let towardSide = belowShared ? "above" : "below";
|
|
751
|
+
if (zone.rotated) {
|
|
752
|
+
awaySide = awaySide === "above" ? "below" : "above";
|
|
753
|
+
towardSide = towardSide === "above" ? "below" : "above";
|
|
754
|
+
}
|
|
755
|
+
setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
|
|
756
|
+
});
|
|
757
|
+
}, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
|
|
758
|
+
if (!zone) return auto;
|
|
759
|
+
if (!ownBounds) return { ...auto, measured: false };
|
|
760
|
+
return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
|
|
761
|
+
}
|
|
762
|
+
|
|
724
763
|
// src/PlayerSetupPanel.tsx
|
|
725
764
|
import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
726
765
|
function getColorPopoverId(idPrefix) {
|
|
727
766
|
return `${idPrefix}-color`;
|
|
728
767
|
}
|
|
768
|
+
function getProfilePopoverId(idPrefix) {
|
|
769
|
+
return `${idPrefix}-profile`;
|
|
770
|
+
}
|
|
729
771
|
function getCpuDifficultyPopoverId(idPrefix) {
|
|
730
772
|
return `${idPrefix}-difficulty`;
|
|
731
773
|
}
|
|
732
|
-
function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange,
|
|
774
|
+
function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, showColor = true, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, humanIcon = "face-man", cpuIcon = "robot" }) {
|
|
733
775
|
const ownHost = usePopoverHost();
|
|
734
776
|
const popover = host ?? ownHost;
|
|
735
777
|
const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false;
|
|
736
778
|
const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || secondPickerId !== void 0 && popover.openId === secondPickerId;
|
|
737
779
|
const showProfilePicker = isHuman && profiles !== void 0 && !!onProfileSelect;
|
|
738
780
|
const showCpuDifficulty = !isHuman && cpuDifficulty !== void 0 && cpuDifficultyOptions !== void 0 && !!onCpuDifficultyChange;
|
|
781
|
+
const seatButton = showProfilePicker || showCpuDifficulty;
|
|
739
782
|
const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null;
|
|
783
|
+
const colorSection = showColor ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: showProfilePicker && selectedProfile ? colorCaption : void 0 } : void 0;
|
|
784
|
+
const seatPickerId = showProfilePicker ? getProfilePopoverId(idPrefix) : getCpuDifficultyPopoverId(idPrefix);
|
|
785
|
+
const seatPickerSize = showProfilePicker ? getProfilePickerContentSize(profiles.length, !!onManageProfiles, colorSection) : getOptionPickerContentSize(cpuDifficultyOptions?.length ?? 0, colorSection);
|
|
786
|
+
const seatPickerAlign = useZoneClampedAlign(seatButton && popover.openId === seatPickerId, seatPickerSize.width, seatPickerSize.height);
|
|
740
787
|
return /* @__PURE__ */ jsxs7(View8, { style: [styles8.panel, ownPopoverOpen && styles8.panelOpen], children: [
|
|
741
|
-
showProfilePicker ? /* @__PURE__ */ jsx10(ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles }) : showCpuDifficulty ? /* @__PURE__ */ jsx10(
|
|
742
|
-
/* @__PURE__ */ jsxs7(View8, { style: [styles8.pickerRow, pickerRowPopoverOpen && styles8.pickerRowOpen], children: [
|
|
743
|
-
/* @__PURE__ */ jsx10(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
|
|
788
|
+
showProfilePicker ? /* @__PURE__ */ jsx10(ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles, colorSection, alignOverride: seatPickerAlign }) : showCpuDifficulty ? /* @__PURE__ */ jsx10(OptionPicker, { id: getCpuDifficultyPopoverId(idPrefix), host: popover, options: cpuDifficultyOptions, value: cpuDifficulty, onChange: onCpuDifficultyChange, color, dark, icon: cpuIcon, colorSection, alignOverride: seatPickerAlign }) : label && /* @__PURE__ */ jsx10(Text6, { variant: "labelSmall", style: { color, fontFamily: labelFontFamily }, children: label }),
|
|
789
|
+
(!seatButton || secondPicker) && /* @__PURE__ */ jsxs7(View8, { style: [styles8.pickerRow, pickerRowPopoverOpen && styles8.pickerRowOpen], children: [
|
|
790
|
+
!seatButton && /* @__PURE__ */ jsx10(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
|
|
744
791
|
secondPicker
|
|
745
792
|
] }),
|
|
746
793
|
isHuman && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color, ready: ready ?? false, onToggleReady })
|
|
@@ -757,9 +804,9 @@ var styles8 = StyleSheet8.create({
|
|
|
757
804
|
panelOpen: {
|
|
758
805
|
zIndex: 100
|
|
759
806
|
},
|
|
760
|
-
// Same gap as `panel`'s own vertical rhythm, reused horizontally - the color
|
|
761
|
-
// present) secondPicker sit side by side within this row instead of stacked in the panel's
|
|
762
|
-
// column.
|
|
807
|
+
// Same gap as `panel`'s own vertical rhythm, reused horizontally - the fallback color swatch and
|
|
808
|
+
// (when present) secondPicker sit side by side within this row instead of stacked in the panel's
|
|
809
|
+
// own column.
|
|
763
810
|
pickerRow: {
|
|
764
811
|
alignItems: "center",
|
|
765
812
|
flexDirection: "row",
|
|
@@ -816,15 +863,15 @@ var styles9 = StyleSheet10.create({
|
|
|
816
863
|
});
|
|
817
864
|
|
|
818
865
|
// src/useQuitConfirmation.ts
|
|
819
|
-
import { useCallback as useCallback3, useEffect as
|
|
866
|
+
import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef2, useState as useState4 } from "react";
|
|
820
867
|
function useQuitConfirmation(hasProgress, onConfirmedBack) {
|
|
821
|
-
const [confirmVisible, setConfirmVisible] =
|
|
868
|
+
const [confirmVisible, setConfirmVisible] = useState4(false);
|
|
822
869
|
const hasProgressRef = useRef2(hasProgress);
|
|
823
|
-
|
|
870
|
+
useEffect3(() => {
|
|
824
871
|
hasProgressRef.current = hasProgress;
|
|
825
872
|
});
|
|
826
873
|
const onConfirmedBackRef = useRef2(onConfirmedBack);
|
|
827
|
-
|
|
874
|
+
useEffect3(() => {
|
|
828
875
|
onConfirmedBackRef.current = onConfirmedBack;
|
|
829
876
|
});
|
|
830
877
|
const requestBack = useCallback3(() => {
|
|
@@ -834,45 +881,6 @@ function useQuitConfirmation(hasProgress, onConfirmedBack) {
|
|
|
834
881
|
const cancelBack = useCallback3(() => setConfirmVisible(false), []);
|
|
835
882
|
return { confirmVisible, requestBack, cancelBack };
|
|
836
883
|
}
|
|
837
|
-
|
|
838
|
-
// src/useZoneClampedAlign.ts
|
|
839
|
-
import { useZoneBounds } from "@tastic/split-screen";
|
|
840
|
-
import { useEffect as useEffect3, useState as useState4 } from "react";
|
|
841
|
-
import { useWindowDimensions as useWindowDimensions3 } from "react-native";
|
|
842
|
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
843
|
-
var EDGE_MARGIN2 = 12;
|
|
844
|
-
function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
|
|
845
|
-
const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
|
|
846
|
-
const zone = useZoneBounds();
|
|
847
|
-
const { height: windowHeight } = useWindowDimensions3();
|
|
848
|
-
const insets = useSafeAreaInsets();
|
|
849
|
-
const shouldMeasure = open && auto.measured && zone !== null;
|
|
850
|
-
const [ownBounds, setOwnBounds] = useState4(null);
|
|
851
|
-
const [prevShouldMeasure, setPrevShouldMeasure] = useState4(shouldMeasure);
|
|
852
|
-
if (shouldMeasure !== prevShouldMeasure) {
|
|
853
|
-
setPrevShouldMeasure(shouldMeasure);
|
|
854
|
-
setOwnBounds(null);
|
|
855
|
-
}
|
|
856
|
-
useEffect3(() => {
|
|
857
|
-
if (!shouldMeasure) return;
|
|
858
|
-
auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
|
|
859
|
-
const belowShared = zone.zoneSide === "belowShared";
|
|
860
|
-
const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
|
|
861
|
-
const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
|
|
862
|
-
const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
|
|
863
|
-
let awaySide = belowShared ? "below" : "above";
|
|
864
|
-
let towardSide = belowShared ? "above" : "below";
|
|
865
|
-
if (zone.rotated) {
|
|
866
|
-
awaySide = awaySide === "above" ? "below" : "above";
|
|
867
|
-
towardSide = towardSide === "above" ? "below" : "above";
|
|
868
|
-
}
|
|
869
|
-
setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
|
|
870
|
-
});
|
|
871
|
-
}, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
|
|
872
|
-
if (!zone) return auto;
|
|
873
|
-
if (!ownBounds) return { ...auto, measured: false };
|
|
874
|
-
return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
|
|
875
|
-
}
|
|
876
884
|
export {
|
|
877
885
|
AchievementCatalogSection,
|
|
878
886
|
AchievementRow,
|
|
@@ -911,6 +919,7 @@ export {
|
|
|
911
919
|
getCpuDifficultyPopoverId,
|
|
912
920
|
getInlineColorPickerContentSize,
|
|
913
921
|
getLabeledDropdownContentHeight,
|
|
922
|
+
getProfilePopoverId,
|
|
914
923
|
getStaggeredWordDuration,
|
|
915
924
|
isCompactDialogHeight,
|
|
916
925
|
loadSkiaWeb,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -85,16 +85,16 @@
|
|
|
85
85
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@infinitetoken/eslint-config": "^0.2.0",
|
|
88
|
-
"@infinitetoken/jest-config": "^0.
|
|
89
|
-
"@infinitetoken/tsconfig": "^0.
|
|
90
|
-
"@rific/auto-paper": "^0.13.
|
|
88
|
+
"@infinitetoken/jest-config": "^0.5.2",
|
|
89
|
+
"@infinitetoken/tsconfig": "^0.6.0",
|
|
90
|
+
"@rific/auto-paper": "^0.13.3",
|
|
91
91
|
"@rific/core": "^0.3.1",
|
|
92
92
|
"@rific/feedback-press": "^0.13.0",
|
|
93
93
|
"@rific/updater": "^0.7.1",
|
|
94
|
-
"@shopify/react-native-skia": "^2.
|
|
94
|
+
"@shopify/react-native-skia": "^2.14.0",
|
|
95
95
|
"@tastic/achievements": "^0.6.0",
|
|
96
96
|
"@tastic/core": "^0.14.0",
|
|
97
|
-
"@tastic/profile": "^0.
|
|
97
|
+
"@tastic/profile": "^0.15.0",
|
|
98
98
|
"@tastic/split-screen": "^0.5.2",
|
|
99
99
|
"@testing-library/dom": "^10.4.1",
|
|
100
100
|
"@testing-library/react": "^16.3.2",
|
|
@@ -108,10 +108,10 @@
|
|
|
108
108
|
"prettier": "^3.8.3",
|
|
109
109
|
"react": "^19.0.0",
|
|
110
110
|
"react-dom": "^19.0.0",
|
|
111
|
-
"react-native": "^0.
|
|
111
|
+
"react-native": "^0.88.0",
|
|
112
112
|
"react-native-paper": "^5.15.2",
|
|
113
|
-
"react-native-reanimated": "^4.
|
|
114
|
-
"react-native-safe-area-context": "^5.10.
|
|
113
|
+
"react-native-reanimated": "^4.7.1",
|
|
114
|
+
"react-native-safe-area-context": "^5.10.1",
|
|
115
115
|
"tsup": "^8.0.0",
|
|
116
116
|
"typescript": "^6.0.3"
|
|
117
117
|
},
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
"@shopify/react-native-skia": ">=2.6.0",
|
|
123
123
|
"@tastic/achievements": ">=0.2.0",
|
|
124
124
|
"@tastic/core": ">=0.12.0",
|
|
125
|
-
"@tastic/profile": ">=0.
|
|
125
|
+
"@tastic/profile": ">=0.15.0",
|
|
126
126
|
"@tastic/split-screen": ">=0.5.0",
|
|
127
127
|
"react": ">=19.0.0",
|
|
128
128
|
"react-native": ">=0.76.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
1
|
+
import { getBlendedColor, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import type { AchievementCatalogRow } from '@tastic/achievements'
|
|
3
3
|
import { Fragment } from 'react'
|
|
4
4
|
import { StyleSheet } from 'react-native'
|
|
@@ -36,9 +36,16 @@ interface Props {
|
|
|
36
36
|
// AchievementRow's own "badge color is a rendering decision" doc comment - @tastic/achievements'
|
|
37
37
|
// (pending) getAchievementCatalogRows deliberately leaves tierColor unresolved-to-badgeColor for
|
|
38
38
|
// the same reason.
|
|
39
|
+
//
|
|
40
|
+
// The check mark and progress bar (AchievementRow's checkColor) are the tier color in dark mode, and
|
|
41
|
+
// the tier color mixed half with black in light mode. Raw gold (#FFD54F) and silver (#C0C0C8) are
|
|
42
|
+
// about 1.4:1 and 1.8:1 against the light screen's white, so a bar or check in either barely shows;
|
|
43
|
+
// mixed, they're about 5:1 and 6:1 (bronze goes from about 3:1 to 9:1). The badge keeps the true tier
|
|
44
|
+
// color: its glyph is black, which reads on all three.
|
|
39
45
|
export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride }: Props) {
|
|
40
46
|
const { dark } = useAutoPaperTheme()
|
|
41
47
|
const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
|
|
48
|
+
const tierAccent = (tierColor: string) => (dark ? tierColor : getBlendedColor('#000000', tierColor, 0.5))
|
|
42
49
|
|
|
43
50
|
return (
|
|
44
51
|
<Fragment>
|
|
@@ -46,7 +53,7 @@ export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg
|
|
|
46
53
|
{label}
|
|
47
54
|
</Text>
|
|
48
55
|
{rows.map((row) => (
|
|
49
|
-
<AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={row.tierColor} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
|
|
56
|
+
<AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={tierAccent(row.tierColor)} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
|
|
50
57
|
))}
|
|
51
58
|
</Fragment>
|
|
52
59
|
)
|
package/src/AchievementRow.tsx
CHANGED
|
@@ -21,10 +21,10 @@ interface Props {
|
|
|
21
21
|
// timestamp - this component has no opinion on date formatting or streak conventions, which vary
|
|
22
22
|
// per app (see LightCycles' own calendar-day-difference convention in its achievements.tsx).
|
|
23
23
|
unlockedLabel?: string
|
|
24
|
-
// Color for the unlocked check-circle icon
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// Color for the unlocked check-circle icon and the locked progress bar - defaults to badgeColor.
|
|
25
|
+
// Its own prop so a caller can tune it apart from the badge: AchievementCatalogSection passes the
|
|
26
|
+
// tier color darkened in light mode, where raw gold and silver barely show on white, while the badge
|
|
27
|
+
// keeps the true tier color behind its black glyph.
|
|
28
28
|
checkColor?: string
|
|
29
29
|
// Only rendered while locked (unlockedLabel is undefined) - 0..1.
|
|
30
30
|
progress?: number
|
|
@@ -400,7 +400,9 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
|
|
|
400
400
|
</View>
|
|
401
401
|
</Dialog>
|
|
402
402
|
<ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={0} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
|
|
403
|
-
|
|
403
|
+
{/* onCancel with no cancelLabel adds no button: it's only so Android back dismisses the
|
|
404
|
+
message the way OK does (ConfirmDialog consumes back either way). */}
|
|
405
|
+
<ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} onCancel={() => setInfoMessage(null)} />
|
|
404
406
|
</RotatedFrame>
|
|
405
407
|
</Portal>
|
|
406
408
|
)
|
package/src/BaseStatsScreen.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
1
|
+
import { FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { Button, IconButton } from '@rific/feedback-press'
|
|
3
3
|
import { useRotation, type ViewRotation } from '@tastic/core'
|
|
4
4
|
import { ReactNode, useState } from 'react'
|
|
5
|
-
import {
|
|
5
|
+
import { type Insets, StyleSheet, View } from 'react-native'
|
|
6
6
|
import { Text } from 'react-native-paper'
|
|
7
7
|
|
|
8
8
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
@@ -30,6 +30,14 @@ interface Props {
|
|
|
30
30
|
// caller that wants something else entirely.
|
|
31
31
|
titleVariant?: MD3TextVariant
|
|
32
32
|
onBack: () => void
|
|
33
|
+
// The header arrow's accessibility label, default 'Back' (same name and default as
|
|
34
|
+
// CornerActionButtons' own backLabel). Without one, VoiceOver and TalkBack announce only "button".
|
|
35
|
+
backLabel?: string
|
|
36
|
+
// Opt-in extra touch area on the header arrow, forwarded as its IconButton's own hitSlop, exactly
|
|
37
|
+
// like CornerActionButtons' hitSlop: the target is the 40pt box, so 4 makes it 48pt, and nothing
|
|
38
|
+
// visible moves. Needs @rific/feedback-press 0.13.0 or later (ignored on older versions, and on
|
|
39
|
+
// web). Omitted means no slop, as before.
|
|
40
|
+
hitSlop?: number | Insets
|
|
33
41
|
insets: { top: number; left: number; bottom: number; right: number }
|
|
34
42
|
// Optional, and always passed as a pair - a caller with nothing resettable (or that deliberately
|
|
35
43
|
// doesn't want a reset action on this screen) omits both rather than one alone. Confirmation copy
|
|
@@ -70,7 +78,7 @@ interface Props {
|
|
|
70
78
|
// convention. `children` is the seam for whatever isn't shared: this component has no opinion on
|
|
71
79
|
// what a "stat" is or how achievements are catalogued - see StatRow/StatSection/AchievementRow for
|
|
72
80
|
// the smaller presentational pieces built to go inside it.
|
|
73
|
-
export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achievements', titleVariant = 'headlineSmall', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', fg: fgOverride, bg: bgOverride, children }: Props) {
|
|
81
|
+
export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achievements', titleVariant = 'headlineSmall', onBack, backLabel = 'Back', hitSlop, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', fg: fgOverride, bg: bgOverride, children }: Props) {
|
|
74
82
|
const { dark, colors } = useAutoPaperTheme()
|
|
75
83
|
const ambientRotation = useRotation()
|
|
76
84
|
const rotation = rotationOverride ?? ambientRotation
|
|
@@ -87,13 +95,15 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
|
|
|
87
95
|
return (
|
|
88
96
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
89
97
|
<View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
|
|
90
|
-
<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={onBack} />
|
|
98
|
+
<IconButton icon='arrow-left' iconColor={fg} size={24} hitSlop={hitSlop} onPress={onBack} accessibilityLabel={backLabel} />
|
|
91
99
|
<Text variant={titleVariant} style={[styles.title, { color: fg }]}>
|
|
92
100
|
{title}
|
|
93
101
|
</Text>
|
|
94
102
|
</View>
|
|
95
103
|
|
|
96
|
-
|
|
104
|
+
{/* Fades out at an edge while there is more past it, the same cue as the Settings sheet's rows and the
|
|
105
|
+
Profiles screen's (Jay, 2026-10-16: every scrolling screen alike). */}
|
|
106
|
+
<FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
|
|
97
107
|
{children}
|
|
98
108
|
|
|
99
109
|
{showReset && (
|
|
@@ -101,7 +111,7 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
|
|
|
101
111
|
{resetLabel}
|
|
102
112
|
</Button>
|
|
103
113
|
)}
|
|
104
|
-
</
|
|
114
|
+
</FadingScrollView>
|
|
105
115
|
|
|
106
116
|
<ConfirmDialog
|
|
107
117
|
visible={confirmResetVisible}
|
|
@@ -139,7 +149,7 @@ const styles = StyleSheet.create({
|
|
|
139
149
|
resetButton: {
|
|
140
150
|
marginTop: 16
|
|
141
151
|
},
|
|
142
|
-
// Bounds the
|
|
152
|
+
// Bounds the scroller (FadingScrollView puts it on its outer box) to the space `container`'s flex:1 actually gives it - without this, a
|
|
143
153
|
// ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
|
|
144
154
|
// can render at its full unclipped content height instead of the screen's), which leaves the
|
|
145
155
|
// header's back button touch target unreliable underneath it.
|
package/src/ConfirmDialog.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { Button } from '@rific/feedback-press'
|
|
3
3
|
import { toRotationStyle, type ViewRotation } from '@tastic/core'
|
|
4
|
-
import { type ReactNode } from 'react'
|
|
5
|
-
import { StyleSheet, View } from 'react-native'
|
|
4
|
+
import { type ReactNode, useEffect, useRef } from 'react'
|
|
5
|
+
import { BackHandler, StyleSheet, View } from 'react-native'
|
|
6
6
|
import { Portal, Text } from 'react-native-paper'
|
|
7
7
|
|
|
8
8
|
import { DialogHeader } from './DialogHeader'
|
|
@@ -43,6 +43,29 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
|
|
|
43
43
|
const { colors } = useAutoPaperTheme()
|
|
44
44
|
const { fg } = useOverlayCardColors()
|
|
45
45
|
const { compact, onLayout } = useCompactDialog(rotation)
|
|
46
|
+
|
|
47
|
+
// Android's back means Cancel while the dialog is up. It's a Portal over a View, not a Modal, so
|
|
48
|
+
// nothing else catches the press: it would fall through to whatever is underneath (the router, or a
|
|
49
|
+
// screen's own back listener) and close that screen with the dialog still up. RN calls the newest
|
|
50
|
+
// listener first, and this one is added when the dialog opens. Always consumed, even with no
|
|
51
|
+
// onCancel: a dialog with only a confirm button has to be answered, not backed out from under.
|
|
52
|
+
//
|
|
53
|
+
// onCancel goes through a ref (kept current after every commit, the useQuitConfirmation idiom) so the
|
|
54
|
+
// listener is added once per opening. Callers pass a new arrow each render, and re-adding the
|
|
55
|
+
// listener on each would move it to the top of the stack, ahead of anything opened above it since.
|
|
56
|
+
const onCancelRef = useRef(onCancel)
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
onCancelRef.current = onCancel
|
|
59
|
+
})
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!visible) return
|
|
62
|
+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
63
|
+
onCancelRef.current?.()
|
|
64
|
+
return true
|
|
65
|
+
})
|
|
66
|
+
return () => subscription.remove()
|
|
67
|
+
}, [visible])
|
|
68
|
+
|
|
46
69
|
if (!visible) return null
|
|
47
70
|
|
|
48
71
|
const accent = destructive ? colors.danger : colors.primary
|