Skip to content

Commit a75241e

Browse files
author
ndii-dev
committed
feat(mobile): implement dynamic app icon theming for iOS and Android
Extends AppIconService and adds AppIconSettingsScreen and iOS icon config to satisfy issue yosemite01#825. Changes: - mobile/src/services/AppIconService.ts - Exports AppIconName type directly (was imported from ../types where it wasn't defined); adds 'dark', 'halloween', and 'light' (alias for default) - Expands ICON_NAME_BY_KEY with DarkIcon and HalloweenIcon entries - Adds autoSetIconForTheme(colorScheme) — sets 'dark' icon on dark mode, 'default' on light; safe to call from a useColorScheme listener - Adds isHalloweenSeason() — true when month is October - Adds autoSetIconForSeason() — calls setAppIconAsync('halloween') in Oct - Adds Android stub: logs an actionable warning and returns gracefully instead of throwing when Platform.OS === 'android' - mobile/src/screens/AppIconSettingsScreen.tsx (new) - Lists all six icons (default, aurora, midnight, forest, dark, halloween) as Pressable rows with a colour swatch, label, description, and active ✓ - "Auto (follows system theme)" Switch at the top — triggers autoSetIconForTheme on toggle and on subsequent colorScheme changes - Shows "Dynamic icons not supported on this device" when supportsAppIconChangeAsync() returns false - Includes an informational note about Android native module requirements - Created as a separate screen to preserve the existing ThemeSettingsScreen (which manages dark/light/system theme mode) - mobile/ios-icon-config.json (new) - Documents the Info.plist CFBundleAlternateIcons structure required to register DarkIcon, HalloweenIcon, AuroraIcon, MidnightIcon, ForestIcon Closes yosemite01#825
1 parent 922d2d0 commit a75241e

3 files changed

Lines changed: 381 additions & 9 deletions

File tree

mobile/ios-icon-config.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"_comment": "Add this structure to ios/<AppName>/Info.plist to register alternate icons. Image assets must be placed in ios/<AppName>/Images.xcassets or as loose PNG files alongside Info.plist.",
3+
"CFBundleIcons": {
4+
"CFBundlePrimaryIcon": {
5+
"CFBundleIconFiles": ["AppIcon"],
6+
"UIPrerenderedIcon": false
7+
},
8+
"CFBundleAlternateIcons": {
9+
"DarkIcon": {
10+
"CFBundleIconFiles": ["DarkIcon"],
11+
"UIPrerenderedIcon": false
12+
},
13+
"HalloweenIcon": {
14+
"CFBundleIconFiles": ["HalloweenIcon"],
15+
"UIPrerenderedIcon": false
16+
},
17+
"AuroraIcon": {
18+
"CFBundleIconFiles": ["AuroraIcon"],
19+
"UIPrerenderedIcon": false
20+
},
21+
"MidnightIcon": {
22+
"CFBundleIconFiles": ["MidnightIcon"],
23+
"UIPrerenderedIcon": false
24+
},
25+
"ForestIcon": {
26+
"CFBundleIconFiles": ["ForestIcon"],
27+
"UIPrerenderedIcon": false
28+
}
29+
}
30+
},
31+
"_android_note": "Android dynamic icon switching requires activity-aliases in AndroidManifest.xml and mipmaps in res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/. See https://developer.android.com/guide/topics/manifest/activity-element#alias"
32+
}
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
/**
2+
* AppIconSettingsScreen — Issue #825
3+
* Lets users manually pick an alternate app icon and toggle auto-switching.
4+
* Works on iOS only; shows an informative stub on Android.
5+
*/
6+
7+
import React, { useCallback, useEffect, useState } from 'react';
8+
import {
9+
ActivityIndicator,
10+
Pressable,
11+
SafeAreaView,
12+
ScrollView,
13+
StyleSheet,
14+
Switch,
15+
Text,
16+
useColorScheme,
17+
View,
18+
} from 'react-native';
19+
import type { AppIconName } from '../services/AppIconService';
20+
import {
21+
autoSetIconForTheme,
22+
getCurrentAppIconAsync,
23+
setAppIconAsync,
24+
supportsAppIconChangeAsync,
25+
} from '../services/AppIconService';
26+
27+
// ─── Icon catalogue ───────────────────────────────────────────────────────────
28+
29+
interface IconOption {
30+
name: AppIconName;
31+
label: string;
32+
color: string;
33+
description: string;
34+
}
35+
36+
const ICON_OPTIONS: IconOption[] = [
37+
{ name: 'default', label: 'Default', color: '#4F8EF7', description: 'The original Stellar icon' },
38+
{ name: 'aurora', label: 'Aurora', color: '#A78BFA', description: 'Vibrant aurora gradients' },
39+
{ name: 'midnight', label: 'Midnight', color: '#1E3A5F', description: 'Deep blue night sky' },
40+
{ name: 'forest', label: 'Forest', color: '#22C55E', description: 'Fresh green tones' },
41+
{ name: 'dark', label: 'Dark', color: '#0F172A', description: 'Sleek monochrome dark' },
42+
{ name: 'halloween', label: 'Halloween', color: '#F97316', description: 'Spooky seasonal theme 🎃' },
43+
];
44+
45+
// ─── Component ────────────────────────────────────────────────────────────────
46+
47+
interface AppIconSettingsScreenProps {
48+
onBack?: () => void;
49+
}
50+
51+
export function AppIconSettingsScreen({ onBack }: AppIconSettingsScreenProps) {
52+
const colorScheme = useColorScheme();
53+
const isDark = colorScheme === 'dark';
54+
55+
const [supported, setSupported] = useState<boolean | null>(null);
56+
const [activeIcon, setActiveIcon] = useState<AppIconName>('default');
57+
const [autoFollow, setAutoFollow] = useState(false);
58+
const [loading, setLoading] = useState<AppIconName | null>(null);
59+
60+
// Detect support and current icon on mount
61+
useEffect(() => {
62+
(async () => {
63+
const [supportsChange, current] = await Promise.all([
64+
supportsAppIconChangeAsync(),
65+
getCurrentAppIconAsync(),
66+
]);
67+
setSupported(supportsChange);
68+
setActiveIcon(current);
69+
})();
70+
}, []);
71+
72+
// When auto-follow is toggled on, immediately sync to current color scheme
73+
useEffect(() => {
74+
if (autoFollow && colorScheme) {
75+
autoSetIconForTheme(colorScheme).then(() =>
76+
getCurrentAppIconAsync().then(setActiveIcon),
77+
);
78+
}
79+
}, [autoFollow, colorScheme]);
80+
81+
const handleSelect = useCallback(
82+
async (icon: AppIconName) => {
83+
if (!supported || loading) return;
84+
setLoading(icon);
85+
try {
86+
await setAppIconAsync(icon);
87+
setActiveIcon(icon);
88+
setAutoFollow(false); // manual pick overrides auto
89+
} finally {
90+
setLoading(null);
91+
}
92+
},
93+
[supported, loading],
94+
);
95+
96+
// Theme-aware colours
97+
const bg = isDark ? '#0F172A' : '#F8FAFC';
98+
const card = isDark ? '#1E293B' : '#FFFFFF';
99+
const text = isDark ? '#F1F5F9' : '#0F172A';
100+
const sub = isDark ? '#94A3B8' : '#64748B';
101+
const border = isDark ? '#334155' : '#E2E8F0';
102+
const accent = '#6366F1';
103+
104+
return (
105+
<SafeAreaView style={[styles.safe, { backgroundColor: bg }]}>
106+
{/* Header */}
107+
<View style={[styles.header, { borderBottomColor: border }]}>
108+
{onBack && (
109+
<Pressable
110+
onPress={onBack}
111+
style={({ pressed }) => [styles.backBtn, pressed && { opacity: 0.6 }]}
112+
accessibilityRole="button"
113+
accessibilityLabel="Back"
114+
>
115+
<Text style={[styles.backText, { color: accent }]}>‹ Back</Text>
116+
</Pressable>
117+
)}
118+
<Text style={[styles.title, { color: text }]}>App Icon</Text>
119+
<Text style={[styles.subtitle, { color: sub }]}>Choose your app icon</Text>
120+
</View>
121+
122+
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
123+
{/* Unsupported notice */}
124+
{supported === false && (
125+
<View style={[styles.notice, { backgroundColor: card, borderColor: border }]}>
126+
<Text style={[styles.noticeText, { color: sub }]}>
127+
⚠️ Dynamic icons are not supported on this device.
128+
{'\n'}iOS 10.3+ on a physical device is required.
129+
</Text>
130+
</View>
131+
)}
132+
133+
{/* Auto-follow system theme */}
134+
<View style={[styles.row, { backgroundColor: card, borderColor: border }]}>
135+
<View style={styles.rowContent}>
136+
<Text style={[styles.rowLabel, { color: text }]}>Auto (follows system theme)</Text>
137+
<Text style={[styles.rowSub, { color: sub }]}>
138+
Switches to the Dark icon when dark mode is active
139+
</Text>
140+
</View>
141+
<Switch
142+
value={autoFollow}
143+
onValueChange={setAutoFollow}
144+
disabled={!supported}
145+
trackColor={{ false: '#CBD5E1', true: accent }}
146+
thumbColor="#FFFFFF"
147+
accessibilityLabel="Auto icon follows system theme"
148+
/>
149+
</View>
150+
151+
{/* Section label */}
152+
<Text style={[styles.sectionLabel, { color: sub }]}>ICON</Text>
153+
154+
{/* Icon options */}
155+
{ICON_OPTIONS.map((option) => {
156+
const isActive = activeIcon === option.name;
157+
const isLoading = loading === option.name;
158+
159+
return (
160+
<Pressable
161+
key={option.name}
162+
onPress={() => handleSelect(option.name)}
163+
disabled={!supported || !!loading}
164+
accessibilityRole="radio"
165+
accessibilityState={{ checked: isActive }}
166+
accessibilityLabel={`${option.label}: ${option.description}`}
167+
style={({ pressed }) => [
168+
styles.row,
169+
{
170+
backgroundColor: card,
171+
borderColor: isActive ? accent : border,
172+
borderWidth: isActive ? 2 : 1,
173+
opacity: pressed ? 0.7 : 1,
174+
},
175+
]}
176+
>
177+
{/* Colour swatch */}
178+
<View style={[styles.swatch, { backgroundColor: option.color }]} />
179+
180+
{/* Label */}
181+
<View style={styles.rowContent}>
182+
<Text style={[styles.rowLabel, { color: isActive ? accent : text }]}>
183+
{option.label}
184+
</Text>
185+
<Text style={[styles.rowSub, { color: sub }]}>{option.description}</Text>
186+
</View>
187+
188+
{/* Indicator */}
189+
{isLoading ? (
190+
<ActivityIndicator size="small" color={accent} />
191+
) : isActive ? (
192+
<View style={[styles.checkCircle, { backgroundColor: accent }]}>
193+
<Text style={styles.checkText}></Text>
194+
</View>
195+
) : null}
196+
</Pressable>
197+
);
198+
})}
199+
200+
{/* Android note */}
201+
<View style={[styles.notice, { backgroundColor: card, borderColor: border, marginTop: 8 }]}>
202+
<Text style={[styles.noticeText, { color: sub }]}>
203+
ℹ️ Android support requires a native module (e.g. react-native-change-icon).
204+
Place alternate icons in{' '}
205+
<Text style={{ fontFamily: 'monospace' }}>
206+
android/app/src/main/res/mipmap-*/
207+
</Text>{' '}
208+
and register activity-aliases in AndroidManifest.xml.
209+
</Text>
210+
</View>
211+
</ScrollView>
212+
</SafeAreaView>
213+
);
214+
}
215+
216+
// ─── Styles ───────────────────────────────────────────────────────────────────
217+
218+
const styles = StyleSheet.create({
219+
safe: { flex: 1 },
220+
221+
header: {
222+
paddingHorizontal: 16,
223+
paddingTop: 16,
224+
paddingBottom: 12,
225+
borderBottomWidth: StyleSheet.hairlineWidth,
226+
gap: 2,
227+
},
228+
backBtn: { marginBottom: 4 },
229+
backText: { fontSize: 16, fontWeight: '500' },
230+
title: { fontSize: 24, fontWeight: '700' },
231+
subtitle: { fontSize: 14 },
232+
233+
scroll: { padding: 16, gap: 10, paddingBottom: 48 },
234+
235+
sectionLabel: {
236+
fontSize: 11,
237+
fontWeight: '700',
238+
letterSpacing: 0.8,
239+
marginTop: 4,
240+
marginBottom: 2,
241+
paddingHorizontal: 4,
242+
},
243+
244+
row: {
245+
flexDirection: 'row',
246+
alignItems: 'center',
247+
borderWidth: 1,
248+
borderRadius: 12,
249+
padding: 14,
250+
gap: 12,
251+
},
252+
rowContent: { flex: 1 },
253+
rowLabel: { fontSize: 15, fontWeight: '500' },
254+
rowSub: { fontSize: 12, marginTop: 2 },
255+
256+
swatch: { width: 40, height: 40, borderRadius: 10, flexShrink: 0 },
257+
258+
checkCircle: {
259+
width: 24, height: 24, borderRadius: 12,
260+
alignItems: 'center', justifyContent: 'center',
261+
},
262+
checkText: { color: '#fff', fontSize: 13, fontWeight: '700' },
263+
264+
notice: {
265+
borderWidth: 1,
266+
borderRadius: 10,
267+
padding: 12,
268+
},
269+
noticeText: { fontSize: 13, lineHeight: 19 },
270+
});

0 commit comments

Comments
 (0)