Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions app/settings/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
StyleSheet,
ScrollView,
TouchableOpacity,
Switch,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
Expand Down Expand Up @@ -43,7 +42,7 @@ function ColorSettingRow({ label, color, onPress, colors }: ColorSettingRowProps

export default function ThemeSettingsScreen() {
const router = useRouter();
const { isDark, toggleTheme, colors, reloadCustomColors } = useTheme();
const { isDark, themeMode, setThemeMode, colors, reloadCustomColors } = useTheme();
const { showToast } = useToast();
const { t } = useTranslation();
const [colorPickerVisible, setColorPickerVisible] = useState(false);
Expand Down Expand Up @@ -89,12 +88,26 @@ export default function ThemeSettingsScreen() {
</Text>
</View>
</View>
<Switch
value={isDark}
onValueChange={toggleTheme}
trackColor={{ false: colors.surfaceOutline, true: colors.primary }}
ios_backgroundColor={colors.surfaceOutline}
/>
<View style={[styles.themeModeContainer, { borderColor: colors.surfaceOutline }]}>
{(['auto', 'light', 'dark'] as const).map((mode) => {
const selected = themeMode === mode;
return (
<TouchableOpacity
key={mode}
style={[
styles.themeModeButton,
selected && { backgroundColor: colors.primary },
]}
onPress={() => setThemeMode(mode)}
activeOpacity={0.7}
>
<Text style={[styles.themeModeText, { color: selected ? '#FFFFFF' : colors.textSecondary }]}>
{t(`screens.settings.themeMode${mode.charAt(0).toUpperCase()}${mode.slice(1)}`)}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
</View>
</View>
Expand Down Expand Up @@ -387,6 +400,21 @@ const styles = StyleSheet.create({
fontSize: 12,
marginTop: 2,
},
themeModeContainer: {
flexDirection: 'row',
borderWidth: 1,
borderRadius: borderRadius.small,
overflow: 'hidden',
},
themeModeButton: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
themeModeText: {
...typography.caption,
fontWeight: '600',
textTransform: 'capitalize',
},
separator: {
height: 1,
marginHorizontal: spacing.md,
Expand All @@ -408,4 +436,3 @@ const styles = StyleSheet.create({
borderWidth: 2,
},
});

8 changes: 8 additions & 0 deletions constants/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export interface ChangelogRelease {
}

export const CHANGELOG: ChangelogRelease[] = [
{
version: '3.2.2',
date: '2026-05-29',
changes: [
'Added an Auto theme mode option that follows the iOS system light/dark appearance',
'Theme settings now include Auto, Light, and Dark mode choices',
],
},
{
version: '3.2.1',
date: '2026-05-29',
Expand Down
36 changes: 26 additions & 10 deletions context/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import { storageService } from '@/services/storage';
import { colorThemeManager, ColorTheme } from '@/services/color-theme-manager';

Expand Down Expand Up @@ -28,7 +29,9 @@ export interface ThemeColors {

interface ThemeContextType {
isDark: boolean;
toggleTheme: () => void;
themeMode: 'auto' | 'light' | 'dark';
setThemeMode: (mode: 'auto' | 'light' | 'dark') => Promise<void>;
toggleTheme: () => Promise<void>;
reloadCustomColors: () => Promise<void>;
colors: ThemeColors;
}
Expand Down Expand Up @@ -109,9 +112,11 @@ const trueBlackColors = {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState(true); // Default to dark theme
const systemColorScheme = useColorScheme();
const [themeMode, setThemeModeState] = useState<'auto' | 'light' | 'dark'>('dark');
const [isLoading, setIsLoading] = useState(true);
const [customColors, setCustomColors] = useState<ColorTheme | null>(null);
const isDark = themeMode === 'auto' ? systemColorScheme !== 'light' : themeMode === 'dark';

useEffect(() => {
loadThemePreference();
Expand All @@ -128,7 +133,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const preferences = await storageService.getPreferences();
const savedTheme = preferences.theme;
if (savedTheme !== undefined) {
setIsDark(savedTheme === true || savedTheme === 'dark');
if (savedTheme === 'auto' || savedTheme === 'light' || savedTheme === 'dark') {
setThemeModeState(savedTheme);
} else {
setThemeModeState(savedTheme === true ? 'dark' : 'light');
}
}
// If no saved preference, default to dark (already set)
} catch (error) {
Expand All @@ -147,23 +156,27 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
};

const toggleTheme = async () => {
const newTheme = !isDark;
setIsDark(newTheme);
const setThemeMode = async (mode: 'auto' | 'light' | 'dark') => {
setThemeModeState(mode);
try {
const preferences = await storageService.getPreferences();
await storageService.savePreferences({
...preferences,
theme: newTheme ? 'dark' : 'light',
theme: mode,
});
// Reload custom colors for new theme
const custom = await colorThemeManager.getCustomColors(newTheme);
const nextIsDark = mode === 'auto' ? systemColorScheme !== 'light' : mode === 'dark';
const custom = await colorThemeManager.getCustomColors(nextIsDark);
setCustomColors(custom);
} catch (error) {
// Ignore theme saving errors
}
};

const toggleTheme = async () => {
const newMode = isDark ? 'light' : 'dark';
await setThemeMode(newMode);
};

const baseColors = isDark ? darkColors : lightColors;
const colors = colorThemeManager.mergeColors(baseColors, customColors) as typeof baseColors;

Expand All @@ -172,6 +185,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<ThemeContext.Provider
value={{
themeMode: 'dark',
setThemeMode,
isDark: true,
toggleTheme,
reloadCustomColors: loadCustomColors,
Expand All @@ -186,6 +201,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<ThemeContext.Provider
value={{
themeMode,
setThemeMode,
isDark,
toggleTheme,
reloadCustomColors: loadCustomColors,
Expand All @@ -207,4 +224,3 @@ export function useTheme(): ThemeContextType {

// Export for testing/debugging
export { darkColors, lightColors, trueBlackColors };

3 changes: 3 additions & 0 deletions locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@
"architecture": "Architecture",
"darkMode": "Dark Mode",
"darkModeHint": "Switch between light and dark theme",
"themeModeAuto": "Auto",
"themeModeLight": "Light",
"themeModeDark": "Dark",
"torrentStateColors": "TORRENT STATE COLORS",
"advancedColors": "ADVANCED COLORS",
"resetColors": "Reset Colors",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "qRemote",
"version": "3.2.1",
"version": "3.2.2",
"main": "index.ts",
"scripts": {
"start": "expo start --go",
Expand Down
2 changes: 1 addition & 1 deletion types/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type AddTorrentDialogField =
| 'cookie';

export interface AppPreferences {
/** 'dark' | 'light'; legacy values stored as boolean are also accepted */
/** 'auto' | 'dark' | 'light'; legacy values stored as boolean are also accepted */
theme: string | boolean;

/** Per-theme color overrides, keyed by 'dark' | 'light' */
Expand Down