feat: widget dark mode, update checker, contact button (v1.0.1)

- Widget adapts to app theme (light/dark/system) via AsyncStorage
- Add "Check for updates" button querying Forgejo releases API
- Add "Contact us or report a bug" mailto link in settings
- Bump version to 1.0.1

Closes #1, closes #2, closes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-25 20:33:17 -05:00
parent c58a4dce2d
commit e6ac92e745
8 changed files with 183 additions and 45 deletions

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.0.0",
"version": "1.0.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform, Switch } from 'react-native';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, KeyboardAvoidingView, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays } from 'lucide-react-native';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, Mail, RefreshCw } from 'lucide-react-native';
import Constants from 'expo-constants';
import { colors } from '@/src/theme/colors';
@ -30,6 +30,7 @@ export default function SettingsScreen() {
const [editingTagId, setEditingTagId] = useState<string | null>(null);
const [tagName, setTagName] = useState('');
const [tagColor, setTagColor] = useState(TAG_COLORS[0]);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const loadTags = useCallback(async () => {
const result = await getAllTags();
@ -90,6 +91,45 @@ export default function SettingsScreen() {
]);
};
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
try {
const res = await fetch(
'https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases/latest'
);
if (!res.ok) throw new Error('API error');
const release = await res.json();
const latestTag: string = release.tag_name ?? '';
const latestVersion = latestTag.replace(/^v/, '');
const currentVersion = Constants.expoConfig?.version ?? '0.0.0';
if (latestVersion && latestVersion !== currentVersion) {
const apkAsset = release.assets?.find(
(a: { name: string }) => a.name?.endsWith('.apk')
);
const downloadUrl = apkAsset?.browser_download_url;
const body = release.body ? `\n\n${release.body}` : '';
Alert.alert(
t('settings.checkUpdate'),
t('settings.newVersion', { version: latestVersion }) + body,
[
{ text: t('common.cancel'), style: 'cancel' },
...(downloadUrl
? [{ text: t('settings.download'), onPress: () => Linking.openURL(downloadUrl) }]
: []),
]
);
} else {
Alert.alert(t('settings.checkUpdate'), t('settings.upToDate'));
}
} catch {
Alert.alert(t('settings.checkUpdate'), t('settings.updateError'));
} finally {
setCheckingUpdate(false);
}
};
return (
<ScrollView className={`flex-1 ${isDark ? 'bg-[#1A1A1A]' : 'bg-creme'}`}>
{/* Theme Section */}
@ -400,13 +440,44 @@ export default function SettingsScreen() {
>
{t('settings.about')}
</Text>
<View className={`overflow-hidden rounded-xl px-4 py-3.5 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<Text className={`text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'}
</Text>
<Text className={`mt-1 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
La Compagnie Maximus
</Text>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<View className={`px-4 py-3.5 border-b ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<Text className={`text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'}
</Text>
<Text className={`mt-1 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}>
La Compagnie Maximus
</Text>
</View>
<Pressable
onPress={handleCheckUpdate}
disabled={checkingUpdate}
className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
{checkingUpdate ? (
<ActivityIndicator size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
) : (
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
)}
<Text
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('settings.checkUpdate')}
</Text>
</Pressable>
<Pressable
onPress={() => Linking.openURL('mailto:lacompagniemaximus@protonmail.com')}
className="flex-row items-center px-4 py-3.5"
>
<Mail size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('settings.contactOrReport')}
</Text>
</Pressable>
</View>
</View>
</ScrollView>

View file

@ -1,7 +1,7 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.0.0",
"version": "1.0.1",
"scripts": {
"start": "expo start",
"android": "expo start --android",

View file

@ -96,7 +96,13 @@
"light": "Light",
"system": "System",
"about": "About",
"version": "Version"
"version": "Version",
"contactOrReport": "Contact us or report a bug",
"checkUpdate": "Check for updates",
"upToDate": "You're up to date!",
"newVersion": "New version available: {{version}}",
"download": "Download",
"updateError": "Unable to check for updates"
},
"notifications": {
"title": "Notifications",

View file

@ -96,7 +96,13 @@
"light": "Clair",
"system": "Système",
"about": "À propos",
"version": "Version"
"version": "Version",
"contactOrReport": "Nous joindre ou signaler un bogue",
"checkUpdate": "Vérifier les mises à jour",
"upToDate": "Vous êtes à jour !",
"newVersion": "Nouvelle version disponible : {{version}}",
"download": "Télécharger",
"updateError": "Impossible de vérifier les mises à jour"
},
"notifications": {
"title": "Notifications",

View file

@ -1,4 +1,4 @@
import { Platform } from 'react-native';
import { Platform, Appearance } from 'react-native';
import { requestWidgetUpdate } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { db } from '../db/client';
@ -8,6 +8,7 @@ import { startOfDay, endOfDay, addWeeks } from 'date-fns';
import { TaskListWidget } from '../widgets/TaskListWidget';
export const WIDGET_DATA_KEY = 'widget:tasks';
export const WIDGET_DARK_KEY = 'widget:isDark';
export interface WidgetTask {
id: string;
@ -95,7 +96,25 @@ export async function syncWidgetData(): Promise<void> {
...noDateTasks.map(toWidgetTask),
];
// Determine dark mode from settings
let isDark = false;
try {
const settingsRaw = await AsyncStorage.getItem('simpl-liste-settings');
if (settingsRaw) {
const settings = JSON.parse(settingsRaw);
const theme: string = settings?.state?.theme ?? 'system';
if (theme === 'dark') {
isDark = true;
} else if (theme === 'system') {
isDark = Appearance.getColorScheme() === 'dark';
}
}
} catch {
// Default to light
}
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(allTasks));
await AsyncStorage.setItem(WIDGET_DARK_KEY, JSON.stringify(isDark));
// Request widget update for all 3 sizes
const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'];
@ -104,7 +123,7 @@ export async function syncWidgetData(): Promise<void> {
await requestWidgetUpdate({
widgetName,
renderWidget: (props) =>
TaskListWidget({ ...props, widgetName, tasks: allTasks }),
TaskListWidget({ ...props, widgetName, tasks: allTasks, isDark }),
widgetNotFound: () => {},
});
} catch {

View file

@ -17,13 +17,26 @@ import { fr } from 'date-fns/locale';
const FONT_REGULAR = 'Inter_400Regular';
const FONT_SEMIBOLD = 'Inter_600SemiBold';
const BG_COLOR = '#FFF8F0' as const;
const TEXT_COLOR = '#1A1A1A' as const;
const TEXT_SECONDARY = '#6B6B6B' as const;
const BORDER_COLOR = '#E5E7EB' as const;
const LIGHT_COLORS = {
bg: '#FFF8F0' as const,
text: '#1A1A1A' as const,
textSecondary: '#6B6B6B' as const,
border: '#E5E7EB' as const,
surface: '#FFFFFF' as const,
checkboxUnchecked: '#D1D5DB' as const,
};
const DARK_COLORS = {
bg: '#1A1A1A' as const,
text: '#F5F5F5' as const,
textSecondary: '#A0A0A0' as const,
border: '#3A3A3A' as const,
surface: '#2A2A2A' as const,
checkboxUnchecked: '#555555' as const,
};
const OVERDUE_COLOR = '#C17767' as const;
const TODAY_COLOR = '#4A90A4' as const;
const CHECKBOX_UNCHECKED = '#D1D5DB' as const;
const DEFAULT_LIST_COLOR = '#4A90A4' as const;
const PRIORITY_COLORS = {
@ -32,13 +45,17 @@ const PRIORITY_COLORS = {
1: '#8BA889' as const,
} as const;
function getColors(isDark: boolean) {
return isDark ? DARK_COLORS : LIGHT_COLORS;
}
function getPriorityDotColor(priority: number): ColorProp | null {
return PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] ?? null;
}
function getDateLabel(dueDate: string | null): { text: string; color: ColorProp } {
function getDateLabel(dueDate: string | null, c: ReturnType<typeof getColors>): { text: string; color: ColorProp } {
if (!dueDate) {
return { text: 'Sans date', color: TEXT_SECONDARY };
return { text: 'Sans date', color: c.textSecondary };
}
const date = new Date(dueDate);
@ -51,22 +68,24 @@ function getDateLabel(dueDate: string | null): { text: string; color: ColorProp
return { text: "Aujourd'hui", color: TODAY_COLOR };
}
if (isTomorrow(date)) {
return { text: 'Demain', color: TEXT_COLOR };
return { text: 'Demain', color: c.text };
}
return {
text: format(date, 'EEE d MMM', { locale: fr }),
color: TEXT_SECONDARY,
color: c.textSecondary,
};
}
interface TaskListWidgetProps extends WidgetInfo {
widgetName: string;
tasks?: WidgetTask[];
isDark?: boolean;
}
function TaskItemRow({ task }: { task: WidgetTask }) {
const dateInfo = getDateLabel(task.dueDate);
function TaskItemRow({ task, isDark }: { task: WidgetTask; isDark: boolean }) {
const c = getColors(isDark);
const dateInfo = getDateLabel(task.dueDate, c);
const priorityColor = getPriorityDotColor(task.priority);
return (
@ -78,7 +97,7 @@ function TaskItemRow({ task }: { task: WidgetTask }) {
paddingVertical: 8,
width: 'match_parent',
borderBottomWidth: 1,
borderColor: BORDER_COLOR,
borderColor: c.border,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: `simplliste:///task/${task.id}` }}
@ -101,7 +120,7 @@ function TaskItemRow({ task }: { task: WidgetTask }) {
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: CHECKBOX_UNCHECKED,
borderColor: c.checkboxUnchecked,
marginRight: 10,
alignItems: 'center',
justifyContent: 'center',
@ -137,7 +156,7 @@ function TaskItemRow({ task }: { task: WidgetTask }) {
style={{
fontSize: 14,
fontFamily: FONT_REGULAR,
color: TEXT_COLOR,
color: c.text,
}}
/>
</FlexWidget>
@ -157,14 +176,15 @@ function TaskItemRow({ task }: { task: WidgetTask }) {
);
}
function SmallWidget({ tasks }: { tasks: WidgetTask[] }) {
function SmallWidget({ tasks, isDark }: { tasks: WidgetTask[]; isDark: boolean }) {
const c = getColors(isDark);
return (
<FlexWidget
style={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BG_COLOR,
backgroundColor: c.bg,
borderRadius: 16,
width: 'match_parent',
height: 'match_parent',
@ -177,7 +197,7 @@ function SmallWidget({ tasks }: { tasks: WidgetTask[] }) {
style={{
fontSize: 14,
fontFamily: FONT_SEMIBOLD,
color: TEXT_COLOR,
color: c.text,
marginBottom: 4,
}}
/>
@ -195,7 +215,7 @@ function SmallWidget({ tasks }: { tasks: WidgetTask[] }) {
style={{
fontSize: 12,
fontFamily: FONT_REGULAR,
color: TEXT_SECONDARY,
color: c.textSecondary,
marginBottom: 8,
}}
/>
@ -228,17 +248,20 @@ function SmallWidget({ tasks }: { tasks: WidgetTask[] }) {
function ListWidgetContent({
tasks,
maxItems,
isDark,
}: {
tasks: WidgetTask[];
maxItems: number;
isDark: boolean;
}) {
const c = getColors(isDark);
const displayTasks = tasks.slice(0, maxItems);
return (
<FlexWidget
style={{
flexDirection: 'column',
backgroundColor: BG_COLOR,
backgroundColor: c.bg,
borderRadius: 16,
width: 'match_parent',
height: 'match_parent',
@ -255,7 +278,7 @@ function ListWidgetContent({
paddingVertical: 10,
width: 'match_parent',
borderBottomWidth: 1,
borderColor: BORDER_COLOR,
borderColor: c.border,
}}
clickAction="OPEN_APP"
>
@ -264,7 +287,7 @@ function ListWidgetContent({
style={{
fontSize: 16,
fontFamily: FONT_SEMIBOLD,
color: TEXT_COLOR,
color: c.text,
}}
/>
<FlexWidget
@ -287,7 +310,7 @@ function ListWidgetContent({
style={{
fontSize: 13,
fontFamily: FONT_REGULAR,
color: TEXT_SECONDARY,
color: c.textSecondary,
}}
/>
</FlexWidget>
@ -303,7 +326,7 @@ function ListWidgetContent({
}}
>
{displayTasks.map((task) => (
<TaskItemRow key={task.id} task={task} />
<TaskItemRow key={task.id} task={task} isDark={isDark} />
))}
</FlexWidget>
) : (
@ -320,7 +343,7 @@ function ListWidgetContent({
style={{
fontSize: 14,
fontFamily: FONT_REGULAR,
color: TEXT_SECONDARY,
color: c.textSecondary,
}}
/>
</FlexWidget>
@ -334,7 +357,7 @@ function ListWidgetContent({
paddingVertical: 8,
width: 'match_parent',
borderTopWidth: 1,
borderColor: BORDER_COLOR,
borderColor: c.border,
}}
clickAction="OPEN_URI"
clickActionData={{ uri: 'simplliste:///task/new' }}
@ -366,11 +389,12 @@ function ListWidgetContent({
export function TaskListWidget(props: TaskListWidgetProps) {
const widgetTasks = props.tasks ?? [];
const widgetName = props.widgetName;
const isDark = props.isDark ?? false;
if (widgetName === 'SimplListeSmall') {
return <SmallWidget tasks={widgetTasks} />;
return <SmallWidget tasks={widgetTasks} isDark={isDark} />;
}
const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4;
return <ListWidgetContent tasks={widgetTasks} maxItems={maxItems} />;
return <ListWidgetContent tasks={widgetTasks} maxItems={maxItems} isDark={isDark} />;
}

View file

@ -1,7 +1,7 @@
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { TaskListWidget } from './TaskListWidget';
import { WIDGET_DATA_KEY, type WidgetTask } from '../services/widgetSync';
import { WIDGET_DATA_KEY, WIDGET_DARK_KEY, type WidgetTask } from '../services/widgetSync';
import { isValidUUID } from '../lib/validation';
function isWidgetTask(item: unknown): item is WidgetTask {
@ -29,6 +29,16 @@ async function getWidgetTasks(): Promise<WidgetTask[]> {
}
}
async function getWidgetIsDark(): Promise<boolean> {
try {
const data = await AsyncStorage.getItem(WIDGET_DARK_KEY);
if (!data) return false;
return JSON.parse(data) === true;
} catch {
return false;
}
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
@ -38,12 +48,13 @@ export async function widgetTaskHandler(
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const tasks = await getWidgetTasks();
const [tasks, isDark] = await Promise.all([getWidgetTasks(), getWidgetIsDark()]);
renderWidget(
TaskListWidget({
...widgetInfo,
widgetName: widgetInfo.widgetName,
tasks,
isDark,
})
);
break;
@ -58,7 +69,7 @@ export async function widgetTaskHandler(
if (!isValidUUID(taskId)) break;
// Update the cached data to remove the completed task immediately
const tasks = await getWidgetTasks();
const [tasks, isDark] = await Promise.all([getWidgetTasks(), getWidgetIsDark()]);
const updatedTasks = tasks.filter((t) => t.id !== taskId);
await AsyncStorage.setItem(
WIDGET_DATA_KEY,
@ -71,6 +82,7 @@ export async function widgetTaskHandler(
...widgetInfo,
widgetName: widgetInfo.widgetName,
tasks: updatedTasks,
isDark,
})
);