[Themes] Implement (#34)

* [Themes] Initial work

* [Themes] Read from __vendetta_themes and early UI (#36)

* [Themes] Read from `__vendetta_themes`

* [Themes] Save as JSON and native theming

* [Themes] Basic UI

* [Themes] Merge processed theme data

* [Themes] Import ReactNative from `@lib/preinit`, oraganize imports

* [Themes] Some minor cleanup

* [Themes] UI overhaul

* [Themes] Minor adjustments

* [Themes] Implement updates, make UI reactive-ish

* [Themes] Move to new format

* [Themes > UI] Last-minute ThemeCard changes

* [Themes] Properly support AMOLED

---------

Co-authored-by: Amsyar Rasyiq <82711525+amsyarasyiq@users.noreply.github.com>
This commit is contained in:
Beef 2023-03-17 21:58:37 +00:00 committed by GitHub
parent 7dc0b1286a
commit 85a83e4873
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 464 additions and 132 deletions

View file

@ -1,5 +1,5 @@
import { findByProps } from "@metro/filters";
import { constants } from "@metro/hoist";
import { constants } from "@metro/common";
//! This module is only found on 165.0+, under the assumption that iOS 165.0 is the same as Android 165.0.
//* In 167.1, most if not all traces of the old color modules were removed.

View file

@ -0,0 +1,91 @@
import { ReactNative as RN, stylesheet } from "@metro/common";
import { Forms } from "@ui/components";
import { getAssetIDByName } from "@ui/assets";
import { semanticColors } from "@ui/color";
const { FormRow, FormSwitch, FormRadio } = Forms;
// TODO: These styles work weirdly. iOS has cramped text, Android with low DPI probably does too. Fix?
const styles = stylesheet.createThemedStyleSheet({
card: {
backgroundColor: semanticColors?.BACKGROUND_SECONDARY,
borderRadius: 5,
marginHorizontal: 10,
marginBottom: 10,
},
header: {
padding: 0,
backgroundColor: semanticColors?.BACKGROUND_TERTIARY,
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
},
actions: {
flexDirection: "row-reverse",
alignItems: "center",
},
icon: {
width: 22,
height: 22,
marginLeft: 5,
tintColor: semanticColors?.INTERACTIVE_NORMAL,
},
})
interface Action {
icon: string;
onPress: () => void;
}
interface CardProps {
index?: number;
headerLabel: string | React.ComponentType;
headerIcon?: string;
toggleType: "switch" | "radio";
toggleValue?: boolean;
onToggleChange?: (v: boolean) => void;
descriptionLabel?: string | React.ComponentType;
actions?: Action[];
}
export default function Card(props: CardProps) {
let pressableState = props.toggleValue ?? false;
return (
<RN.View style={[styles.card, {marginTop: props.index === 0 ? 10 : 0}]}>
<FormRow
style={styles.header}
label={props.headerLabel}
leading={props.headerIcon && <FormRow.Icon source={getAssetIDByName(props.headerIcon)} />}
trailing={props.toggleType === "switch" ?
(<FormSwitch
style={RN.Platform.OS === "android" && { marginVertical: -15 }}
value={props.toggleValue}
onValueChange={props.onToggleChange}
/>)
:
(<RN.Pressable onPress={() => {
pressableState = !pressableState;
props.onToggleChange?.(pressableState)
}}>
{/* TODO: Look into making this respect brand color */}
<FormRadio selected={props.toggleValue} />
</RN.Pressable>)
}
/>
<FormRow
label={props.descriptionLabel}
trailing={
<RN.View style={styles.actions}>
{props.actions?.map(({ icon, onPress }) => (
<RN.TouchableOpacity
onPress={onPress}
>
<RN.Image style={styles.icon} source={getAssetIDByName(icon)} />
</RN.TouchableOpacity>
))}
</RN.View>
}
/>
</RN.View>
)
}

View file

@ -1,12 +1,8 @@
import { clipboard, stylesheet } from "@metro/common";
import { ReactNative as RN, clipboard, stylesheet } from "@metro/common";
import { HTTP_REGEX } from "@lib/constants";
import { installPlugin } from "@lib/plugins";
import { showInputAlert } from "@ui/alerts";
import { getAssetIDByName } from "@ui/assets";
import { semanticColors } from "@ui/color";
import { General } from "@ui/components";
const { TouchableOpacity, Image } = General;
const styles = stylesheet.createThemedStyleSheet({
icon: {
@ -15,21 +11,27 @@ const styles = stylesheet.createThemedStyleSheet({
}
});
export default function InstallPluginButton() {
interface InstallButtonProps {
alertTitle: string;
installFunction: (id: string) => Promise<void>;
}
export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) {
return (
<TouchableOpacity onPress={() =>
<RN.TouchableOpacity onPress={() =>
clipboard.getString().then((content) =>
showInputAlert({
title: "Install Plugin",
title: alertTitle,
initialValue: HTTP_REGEX.test(content) ? content : "",
placeholder: "https://example.com/",
onConfirm: installPlugin,
onConfirm: (input: string) => fetchFunction(input),
confirmText: "Install",
confirmColor: undefined,
cancelText: "Cancel"
}))
})
)
}>
<Image style={styles.icon} source={getAssetIDByName("ic_add_24px")} />
</TouchableOpacity >
<RN.Image style={styles.icon} source={getAssetIDByName("ic_add_24px")} />
</RN.TouchableOpacity>
);
};
}

View file

@ -1,41 +1,10 @@
import { ButtonColors, Plugin } from "@types";
import { ReactNative as RN, stylesheet, NavigationNative } from "@metro/common";
import { Forms, General } from "@ui/components";
import { NavigationNative, clipboard } from "@metro/common";
import { getAssetIDByName } from "@ui/assets";
import { showToast } from "@ui/toasts";
import { showConfirmationAlert } from "@ui/alerts";
import { semanticColors } from "@ui/color";
import { removePlugin, startPlugin, stopPlugin, getSettings } from "@lib/plugins";
import copyText from "@utils/copyText";
const { FormRow, FormSwitch } = Forms;
const { TouchableOpacity, Image } = General;
// TODO: These styles work weirdly. iOS has cramped text, Android with low DPI probably does too. Fix?
const styles = stylesheet.createThemedStyleSheet({
card: {
backgroundColor: semanticColors?.BACKGROUND_SECONDARY,
borderRadius: 5,
marginHorizontal: 10,
marginBottom: 10,
},
header: {
padding: 0,
backgroundColor: semanticColors?.BACKGROUND_TERTIARY,
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
},
actions: {
flexDirection: "row-reverse",
alignItems: "center",
},
icon: {
width: 22,
height: 22,
marginLeft: 5,
tintColor: semanticColors?.INTERACTIVE_NORMAL,
}
})
import Card from "@ui/settings/components/Card";
interface PluginCardProps {
plugin: Plugin;
@ -50,75 +19,63 @@ export default function PluginCard({ plugin, index }: PluginCardProps) {
// This is needed because of React™
if (removed) return null;
return (
<RN.View style={[styles.card, {marginTop: index === 0 ? 10 : 0}]}>
<FormRow
style={styles.header}
// TODO: Actually make use of user IDs
label={`${plugin.manifest.name} by ${plugin.manifest.authors.map(i => i.name).join(", ")}`}
leading={<FormRow.Icon source={getAssetIDByName(plugin.manifest.vendetta?.icon || "ic_application_command_24px")} />}
trailing={
<FormSwitch
style={RN.Platform.OS === "android" && { marginVertical: -15 }}
value={plugin.enabled}
onValueChange={(v: boolean) => {
return (
<Card
index={index}
// TODO: Actually make use of user IDs
headerLabel={`${plugin.manifest.name} by ${plugin.manifest.authors.map(i => i.name).join(", ")}`}
headerIcon={plugin.manifest.vendetta?.icon || "ic_application_command_24px"}
toggleType="switch"
toggleValue={plugin.enabled}
onToggleChange={(v: boolean) => {
try {
if (v) startPlugin(plugin.id); else stopPlugin(plugin.id);
} catch (e) {
showToast((e as Error).message, getAssetIDByName("Small"));
}
}}
descriptionLabel={plugin.manifest.description}
actions={[
{
icon: "ic_message_delete",
onPress: () => showConfirmationAlert({
title: "Wait!",
content: `Are you sure you wish to delete ${plugin.manifest.name}?`,
confirmText: "Delete",
cancelText: "Cancel",
confirmColor: ButtonColors.RED,
onConfirm: () => {
try {
if (v) startPlugin(plugin.id); else stopPlugin(plugin.id);
removePlugin(plugin.id);
setRemoved(true);
} catch (e) {
showToast((e as Error).message, getAssetIDByName("Small"));
}
}}
/>
}
/>
<FormRow
label={plugin.manifest.description}
trailing={
<RN.View style={styles.actions}>
<TouchableOpacity
onPress={() => showConfirmationAlert({
title: "Wait!",
content: `Are you sure you wish to delete ${plugin.manifest.name}?`,
confirmText: "Delete",
cancelText: "Cancel",
confirmColor: ButtonColors.RED,
onConfirm: () => {
try {
removePlugin(plugin.id);
setRemoved(true);
} catch (e) {
showToast((e as Error).message, getAssetIDByName("Small"));
}
}
})}
>
<Image style={styles.icon} source={getAssetIDByName("ic_message_delete")} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
copyText(plugin.id);
showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link"));
}}
>
<Image style={styles.icon} source={getAssetIDByName("copy")} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
plugin.update = !plugin.update;
showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
}}
>
<Image style={styles.icon} source={getAssetIDByName(plugin.update ? "Check" : "Small")} />
</TouchableOpacity>
{settings && <TouchableOpacity onPress={() => navigation.push("VendettaCustomPage", {
title: plugin.manifest.name,
render: settings,
})}>
<Image style={styles.icon} source={getAssetIDByName("settings")} />
</TouchableOpacity>}
</RN.View>
}
/>
</RN.View>
}
}),
},
{
icon: "copy",
onPress: () => {
clipboard.setString(plugin.id);
showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link"));
},
},
{
icon: plugin.update ? "Check" : "Small",
onPress: () => {
plugin.update = !plugin.update;
showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
}
},
...(settings ? [{
icon: "settings",
onPress: () => navigation.push("VendettaCustomPage", {
title: plugin.manifest.name,
render: settings,
})
}] : []),
]}
/>
)
}

View file

@ -26,6 +26,17 @@ export default function SettingsSection() {
trailing={FormRow.Arrow}
onPress={() => navigation.push("VendettaPlugins")}
/>
{window.__vendetta_loader?.features.themes && (
<>
<FormDivider />
<FormRow
label="Themes"
leading={<FormRow.Icon source={getAssetIDByName("ic_theme_24px")} />}
trailing={FormRow.Arrow}
onPress={() => navigation.push("VendettaThemes")}
/>
</>
)}
{settings.developerSettings && (
<>
<FormDivider />

View file

@ -0,0 +1,66 @@
import { ButtonColors, Theme } from "@types";
import { clipboard } from "@metro/common";
import { removeTheme, selectTheme } from "@lib/themes";
import { getAssetIDByName } from "@ui/assets";
import { showConfirmationAlert } from "@ui/alerts";
import { showToast } from "@ui/toasts";
import Card from "@ui/settings/components/Card";
interface ThemeCardProps {
theme: Theme;
index: number;
}
async function selectAndReload(value: boolean, id: string) {
await selectTheme(value ? id : "default");
window.nativeModuleProxy.BundleUpdaterManager.reload();
}
export default function ThemeCard({ theme, index }: ThemeCardProps) {
const [removed, setRemoved] = React.useState(false);
// This is needed because of React™
if (removed) return null;
const authors = theme.data.authors;
return (
<Card
index={index}
headerLabel={`${theme.data.name} ${authors ? `by ${authors.map(i => i.name).join(", ")}` : ""}`}
descriptionLabel={theme.data.description ?? "No description."}
toggleType="radio"
toggleValue={theme.selected}
onToggleChange={(v: boolean) => {
selectAndReload(v, theme.id);
}}
actions={[
{
icon: "ic_message_delete",
onPress: () => showConfirmationAlert({
title: "Wait!",
content: `Are you sure you wish to delete ${theme.data.name}?`,
confirmText: "Delete",
cancelText: "Cancel",
confirmColor: ButtonColors.RED,
onConfirm: () => {
removeTheme(theme.id).then((wasSelected) => {
setRemoved(true);
if (wasSelected) selectAndReload(false, theme.id);
}).catch((e) => {
showToast((e as Error).message, getAssetIDByName("Small"));
})
}
}),
},
{
icon: "copy",
onPress: () => {
clipboard.setString(theme.id);
showToast("Copied theme URL to clipboard.", getAssetIDByName("toast_copy_link"));
},
},
]}
/>
)
}

View file

@ -1,12 +1,15 @@
import { NavigationNative, i18n } from "@metro/common";
import { findByDisplayName } from "@metro/filters";
import { after } from "@lib/patcher";
import { installPlugin } from "@lib/plugins";
import { installTheme } from "@lib/themes";
import findInReactTree from "@utils/findInReactTree";
import ErrorBoundary from "@ui/components/ErrorBoundary";
import SettingsSection from "@ui/settings/components/SettingsSection";
import InstallPluginButton from "@ui/settings/components/InstallPluginButton";
import InstallButton from "@ui/settings/components/InstallButton";
import General from "@ui/settings/pages/General";
import Plugins from "@ui/settings/pages/Plugins";
import Themes from "@ui/settings/pages/Themes";
import Developer from "@ui/settings/pages/Developer";
import AssetBrowser from "@ui/settings/pages/AssetBrowser";
@ -26,7 +29,12 @@ export default function initSettings() {
VendettaPlugins: {
title: "Plugins",
render: Plugins,
headerRight: InstallPluginButton,
headerRight: () => <InstallButton alertTitle="Install Plugin" installFunction={installPlugin} />,
},
VendettaThemes: {
title: "Themes",
render: Themes,
headerRight: () => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />,
},
VendettaDeveloper: {
title: "Developer",

View file

@ -0,0 +1,21 @@
import { themes } from "@/lib/themes";
import { useProxy } from "@lib/storage";
import { ReactNative as RN } from "@metro/common";
import ErrorBoundary from "@ui/components/ErrorBoundary";
import ThemeCard from "@ui/settings/components/ThemeCard";
export default function Themes() {
useProxy(themes);
return (
<ErrorBoundary>
<RN.View style={{ flex: 1 }}>
<RN.FlatList
data={Object.values(themes)}
renderItem={({ item, index }) => <ThemeCard theme={item} index={index} />}
keyExtractor={item => item.id}
/>
</RN.View>
</ErrorBoundary>
)
}