From e6ac92e74574f3ed018f71bf490d0294487cc989 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 25 Feb 2026 20:33:17 -0500 Subject: [PATCH] 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 --- app.json | 2 +- app/(tabs)/settings.tsx | 89 ++++++++++++++++++++++++++++---- package.json | 2 +- src/i18n/en.json | 8 ++- src/i18n/fr.json | 8 ++- src/services/widgetSync.ts | 23 ++++++++- src/widgets/TaskListWidget.tsx | 78 ++++++++++++++++++---------- src/widgets/widgetTaskHandler.ts | 18 +++++-- 8 files changed, 183 insertions(+), 45 deletions(-) diff --git a/app.json b/app.json index 22eea64..a183807 100644 --- a/app.json +++ b/app.json @@ -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", diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index c7c98d0..9fad29c 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -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(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 ( {/* Theme Section */} @@ -400,13 +440,44 @@ export default function SettingsScreen() { > {t('settings.about')} - - - Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'} - - - La Compagnie Maximus - + + + + Simpl-Liste {t('settings.version')} {Constants.expoConfig?.version ?? '1.0.0'} + + + La Compagnie Maximus + + + + {checkingUpdate ? ( + + ) : ( + + )} + + {t('settings.checkUpdate')} + + + Linking.openURL('mailto:lacompagniemaximus@protonmail.com')} + className="flex-row items-center px-4 py-3.5" + > + + + {t('settings.contactOrReport')} + + diff --git a/package.json b/package.json index db01f52..76a7258 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 8d3970e..5ecf688 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 1afc52a..738d9e3 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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", diff --git a/src/services/widgetSync.ts b/src/services/widgetSync.ts index 8082477..8b058ae 100644 --- a/src/services/widgetSync.ts +++ b/src/services/widgetSync.ts @@ -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 { ...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 { await requestWidgetUpdate({ widgetName, renderWidget: (props) => - TaskListWidget({ ...props, widgetName, tasks: allTasks }), + TaskListWidget({ ...props, widgetName, tasks: allTasks, isDark }), widgetNotFound: () => {}, }); } catch { diff --git a/src/widgets/TaskListWidget.tsx b/src/widgets/TaskListWidget.tsx index ca9ce89..0d9a0a6 100644 --- a/src/widgets/TaskListWidget.tsx +++ b/src/widgets/TaskListWidget.tsx @@ -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): { 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, }} /> @@ -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 ( @@ -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 ( @@ -264,7 +287,7 @@ function ListWidgetContent({ style={{ fontSize: 16, fontFamily: FONT_SEMIBOLD, - color: TEXT_COLOR, + color: c.text, }} /> @@ -303,7 +326,7 @@ function ListWidgetContent({ }} > {displayTasks.map((task) => ( - + ))} ) : ( @@ -320,7 +343,7 @@ function ListWidgetContent({ style={{ fontSize: 14, fontFamily: FONT_REGULAR, - color: TEXT_SECONDARY, + color: c.textSecondary, }} /> @@ -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 ; + return ; } const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4; - return ; + return ; } diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts index e2a8a06..3686a28 100644 --- a/src/widgets/widgetTaskHandler.ts +++ b/src/widgets/widgetTaskHandler.ts @@ -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 { } } +async function getWidgetIsDark(): Promise { + 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 { @@ -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, }) );