Improve install UX with validation and feedback
This commit is contained in:
parent
1c740dcdb1
commit
b3d91fad93
3 changed files with 407 additions and 51 deletions
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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` }] }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue