Improve install UX with validation and feedback

This commit is contained in:
kmmiio99o 2025-10-31 20:43:11 +01:00
parent 1c740dcdb1
commit b3d91fad93
3 changed files with 407 additions and 51 deletions

View file

@ -69,8 +69,16 @@ function InputAlert(props: {
const [value, setValue] = React.useState("");
const [error, setError] = React.useState("");
const [isFetching, setIsFetching] = React.useState(false);
const [validUrl, setValidUrl] = React.useState(false);
// Check URL validity whenever value changes
React.useEffect(() => {
setValidUrl(isValidHttpUrl(value));
}, [value]);
function onConfirmWrapper() {
if (!value || !isValidHttpUrl(value)) return;
setIsFetching(true);
props
@ -87,24 +95,37 @@ function InputAlert(props: {
title={props.label}
content="Type in the source URL you want to install from:"
extraContent={
<Stack style={{ marginTop: -12 }}>
<TextInput
autoFocus={true}
isClearable={true}
value={value}
onChange={(v: string) => {
setValue(v);
if (error) setError("");
<Stack style={{ marginTop: -8, gap: 12 }}>
<View
style={{
backgroundColor: "rgba(0,0,0,0.05)",
borderRadius: 8,
padding: 4,
paddingHorizontal: 8,
}}
>
<TextInput
autoFocus={true}
isClearable={true}
value={value}
onChange={(v: string) => {
setValue(v);
if (error) setError("");
}}
returnKeyType="done"
onSubmitEditing={onConfirmWrapper}
state={error ? "error" : undefined}
errorMessage={error || undefined}
style={{ fontSize: 16 }}
/>
</View>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
returnKeyType="done"
onSubmitEditing={onConfirmWrapper}
state={error ? "error" : undefined}
errorMessage={error || undefined}
/>
<ScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
style={{ gap: 8 }}
>
<Button
size="sm"
@ -112,28 +133,38 @@ function InputAlert(props: {
text="Import from clipboard"
icon={findAssetId("ClipboardListIcon")}
onPress={() =>
clipboard.getString().then((str: string) => setValue(str))
clipboard
.getString()
.then((str: string) => setValue(str.trim()))
}
/>
</ScrollView>
</View>
</Stack>
}
actions={
<Stack>
{/* Manual button as we don't want alert to immediately dismiss when we tap on it */}
<Button
loading={isFetching}
text="Install"
variant="primary"
disabled={!value || !isValidHttpUrl(value)}
onPress={onConfirmWrapper}
/>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<AlertActionButton
disabled={isFetching}
text="Cancel"
variant="secondary"
style={{ flex: 1 }}
/>
</Stack>
<Button
loading={isFetching}
text={isFetching ? "Installing..." : "Install"}
variant="primary"
onPress={onConfirmWrapper}
icon={!isFetching ? findAssetId("DownloadIcon") : undefined}
style={{ flex: 1 }}
/>
</View>
}
/>
);

View file

@ -39,7 +39,10 @@ import {
AlertActions,
TableRow,
TableRowGroup,
BottomSheetTitleHeader,
} from "@metro/common/components";
import { showSheet } from "@lib/ui/sheets";
import { proxyLazy } from "@lib/utils/lazy";
import { UnifiedPluginModel } from "./models";
import unifyBunnyPlugin from "./models/bunny";
@ -57,6 +60,8 @@ const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() =>
findByProps("showSimpleActionSheet"),
);
const dismissAlert = proxyLazy(() => findByProps("close", "openLazy").close);
interface BaseAddonData {
name: string;
description: string;
@ -105,6 +110,10 @@ function InstallButton({
const [rotation, setRotation] = React.useState(0);
const [pulse, setPulse] = React.useState(1);
const continueInstallation = () => {
performInstall();
};
const handleInstall = async () => {
if (isInstalling) return;
@ -158,20 +167,113 @@ function InstallButton({
try {
await VdPluginManager.installPlugin(addon.installUrl);
setRefreshTick((prev: number) => prev + 1);
// Show success feedback
showSheet("PluginInstalledSheet", () => (
<ActionSheet>
<BottomSheetTitleHeader title="Plugin Installed" />
<View style={{ padding: 16, alignItems: "center", gap: 12 }}>
<View
style={{
backgroundColor: "rgba(67, 181, 129, 0.1)",
borderRadius: 50,
padding: 16,
}}
>
<Image
source={findAssetId("CheckmarkCircle")}
style={{
width: 32,
height: 32,
tintColor: "#43b581",
}}
/>
</View>
<Text variant="heading-md/bold">
{addon.name} installed successfully!
</Text>
<Text
variant="text-md/medium"
color="text-muted"
style={{ textAlign: "center" }}
>
The plugin has been added to your plugins list.
</Text>
<Button
size="md"
text="Open Plugin Settings"
variant="primary"
icon={findAssetId("SettingsIcon")}
onPress={() => hideActionSheet()}
/>
</View>
</ActionSheet>
));
} catch (e) {
openAlert(
"bunny-plugin-install-failed",
<AlertModalComponent
title="Install Failed"
content={`Unable to install plugin from '${addon.installUrl}':`}
title="Installation Failed"
content="Failed to install plugin"
extraContent={
<Card>
<Text variant="text-md/normal">
{e instanceof Error ? e.message : String(e)}
</Text>
</Card>
<Stack spacing={16} style={{ width: "100%" }}>
<View style={{ alignItems: "center", marginTop: 8 }}>
<View
style={{
backgroundColor: "rgba(240, 71, 71, 0.1)",
borderRadius: 50,
padding: 16,
marginBottom: 12,
}}
>
<Image
source={findAssetId("ErrorCircle")}
style={{
width: 32,
height: 32,
tintColor: "#f04747",
}}
/>
</View>
</View>
<Card
style={{
backgroundColor: "rgba(0,0,0,0.05)",
padding: 12,
borderRadius: 8,
}}
>
<Text variant="text-md/semibold" color="text-danger">
{e instanceof Error ? e.message : String(e)}
</Text>
</Card>
</Stack>
}
actions={
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
gap: 8,
}}
>
<AlertActionButton
text="Cancel"
variant="secondary"
style={{ flex: 1 }}
/>
<AlertActionButton
text="Try Again"
icon={findAssetId("RetryIcon")}
variant="primary"
style={{ flex: 1 }}
onPress={() => {
dismissAlert("bunny-plugin-install-failed");
setTimeout(performInstall, 500);
}}
/>
</View>
}
actions={<AlertActionButton text="Okay" variant="primary" />}
/>,
);
} finally {
@ -200,6 +302,12 @@ function InstallButton({
icon={findAssetId("TrashIcon")}
onPress={handleUninstall}
disabled={isQuickInstall}
style={{
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 1,
}}
/>
);
}
@ -212,6 +320,12 @@ function InstallButton({
variant="primary"
disabled={isInstalling}
loading={isInstalling}
style={{
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 1,
}}
icon={
isInstalling ? (
<View style={{ transform: [{ rotate: `${rotation}deg` }] }}>

View file

@ -103,8 +103,123 @@ function BrowserThemeCard({
await installTheme(themeId);
setRefreshTick((prev) => prev + 1);
forceUpdate();
// Show success feedback
showSheet("ThemeInstalledSheet", () => (
<ActionSheet>
<BottomSheetTitleHeader title="Theme Installed" />
<View style={{ padding: 16, alignItems: "center", gap: 12 }}>
<View
style={{
backgroundColor: "rgba(67, 181, 129, 0.1)",
borderRadius: 50,
padding: 16,
}}
>
<Image
source={findAssetId("CheckmarkCircle")}
style={{
width: 32,
height: 32,
tintColor: "#43b581",
}}
/>
</View>
<Text variant="heading-md/bold">
{name} installed successfully!
</Text>
<Text
variant="text-md/medium"
color="text-muted"
style={{ textAlign: "center" }}
>
The theme has been added to your themes list.
</Text>
<View
style={{
flexDirection: "row",
gap: 8,
width: "100%",
justifyContent: "space-between",
}}
>
<Button
size="md"
text="Cancel"
variant="secondary"
style={{ flex: 1 }}
onPress={() => hideActionSheet()}
/>
<Button
size="md"
text="Open Settings"
variant="primary"
icon={findAssetId("ThemeLightIcon")}
style={{ flex: 1 }}
onPress={() => hideActionSheet()}
/>
</View>
</View>
</ActionSheet>
));
} catch (e) {
console.error("Failed to install theme:", e);
// Show error feedback
showSheet("ThemeInstallFailedSheet", () => (
<ActionSheet>
<BottomSheetTitleHeader title="Installation Failed" />
<View style={{ padding: 16, alignItems: "center", gap: 12 }}>
<View
style={{
backgroundColor: "rgba(240, 71, 71, 0.1)",
borderRadius: 50,
padding: 16,
}}
>
<Image
source={findAssetId("ErrorCircle")}
style={{
width: 32,
height: 32,
tintColor: "#f04747",
}}
/>
</View>
<Text variant="heading-md/bold">Failed to install theme</Text>
<Card style={{ width: "100%" }}>
<Text variant="text-md/medium">
{e instanceof Error ? e.message : String(e)}
</Text>
</Card>
<View
style={{
flexDirection: "row",
gap: 8,
width: "100%",
justifyContent: "space-between",
}}
>
<Button
size="md"
text="Cancel"
variant="secondary"
style={{ flex: 1 }}
onPress={() => hideActionSheet()}
/>
<Button
size="md"
text="Try Again"
variant="primary"
icon={findAssetId("RetryIcon")}
style={{ flex: 1 }}
onPress={() => {
hideActionSheet();
setTimeout(handleInstall, 500);
}}
/>
</View>
</View>
</ActionSheet>
));
} finally {
clearInterval(animationInterval);
setRotation(0);
@ -265,7 +380,7 @@ export default function Themes() {
const [refreshTick, setRefreshTick] = React.useState(0);
React.useEffect(() => {
const headerButtons = [];
const headerButtons: React.ReactNode[] = [];
// Add mode toggle button
headerButtons.push(
@ -275,6 +390,12 @@ export default function Themes() {
variant="secondary"
text={mode === "installed" ? "Browse" : "Installed"}
icon={findAssetId(mode === "installed" ? "LinkIcon" : "DownloadIcon")}
style={{
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
}}
onPress={() => setMode(mode === "installed" ? "browse" : "installed")}
/>,
);
@ -425,7 +546,70 @@ export default function Themes() {
}}
installAction={{
label: "Install a theme",
fetchFn: installTheme,
fetchFn: async (url) => {
try {
await installTheme(url);
showSheet("ThemeInstalledSheet", () => (
<ActionSheet>
<BottomSheetTitleHeader title="Theme Installed" />
<View style={{ padding: 16, alignItems: "center", gap: 12 }}>
<View
style={{
backgroundColor: "rgba(67, 181, 129, 0.1)",
borderRadius: 50,
padding: 16,
}}
>
<Image
source={findAssetId("CheckmarkCircle")}
style={{
width: 32,
height: 32,
tintColor: "#43b581",
}}
/>
</View>
<Text variant="heading-md/bold">
Theme installed successfully!
</Text>
<Text
variant="text-md/medium"
color="text-muted"
style={{ textAlign: "center" }}
>
The theme has been added to your themes list.
</Text>
<View
style={{
flexDirection: "row",
gap: 8,
width: "100%",
justifyContent: "space-between",
}}
>
<Button
size="md"
text="Cancel"
variant="secondary"
style={{ flex: 1 }}
onPress={() => hideActionSheet()}
/>
<Button
size="md"
text="Open Settings"
variant="primary"
icon={findAssetId("CheckmarkIcon")}
style={{ flex: 1 }}
onPress={() => hideActionSheet()}
/>
</View>
</View>
</ActionSheet>
));
} catch (e) {
throw e; // Let the original error handler deal with this
}
},
}}
items={Object.values(themes)}
safeModeHint={{
@ -452,27 +636,54 @@ export default function Themes() {
<View
style={{
flex: 1,
paddingHorizontal: 8,
paddingHorizontal: 16,
justifyContent: "center",
alignItems: "center",
}}
>
<Card style={{ gap: 8 }}>
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
An error occurred while fetching the repository
</Text>
<Text
style={{ textAlign: "center" }}
variant="text-sm/medium"
color="text-muted"
<Card style={{ gap: 16, padding: 20, width: "100%", maxWidth: 400 }}>
<View style={{ alignItems: "center" }}>
<View
style={{
backgroundColor: "rgba(240, 71, 71, 0.1)",
borderRadius: 50,
padding: 16,
marginBottom: 12,
}}
>
<Image
source={findAssetId("ErrorCircle")}
style={{ width: 32, height: 32, tintColor: "#f04747" }}
/>
</View>
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
Connection Error
</Text>
<Text
style={{ textAlign: "center", marginTop: 8, marginBottom: 8 }}
variant="text-md/medium"
color="text-muted"
>
An error occurred while fetching the theme repository:
</Text>
</View>
<Card
style={{
backgroundColor: "rgba(0,0,0,0.05)",
padding: 12,
borderRadius: 8,
}}
>
{error}
</Text>
<Text variant="text-md/semibold" color="text-danger">
{error}
</Text>
</Card>
<Button
size="lg"
text="Refetch"
text="Try Again"
onPress={fetchThemes}
icon={findAssetId("RetryIcon")}
style={{ marginTop: 8 }}
/>
</Card>
</View>