[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:
parent
7dc0b1286a
commit
85a83e4873
15 changed files with 464 additions and 132 deletions
|
@ -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.
|
||||
|
|
91
src/ui/settings/components/Card.tsx
Normal file
91
src/ui/settings/components/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}] : []),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
|
|
66
src/ui/settings/components/ThemeCard.tsx
Normal file
66
src/ui/settings/components/ThemeCard.tsx
Normal 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"));
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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",
|
||||
|
|
21
src/ui/settings/pages/Themes.tsx
Normal file
21
src/ui/settings/pages/Themes.tsx
Normal 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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue