Skip to content

Commit f81bd96

Browse files
committed
Implement daily study reminders and enhance settings functionality. Added support for configuring reminder time and enabling/disabling notifications. Updated database schema to store reminder settings and adjusted UI components for better user experience. Bump version to 2.0.1.
1 parent 43491bb commit f81bd96

17 files changed

Lines changed: 635 additions & 48 deletions

.cursor/rules/commit-messages.mdc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
description: Commit messages must be meaningful to end users
3+
alwaysApply: true
4+
---
5+
6+
# Commit messages for users
7+
8+
Write commit messages for people who read GitHub release notes and history—not for developers only.
9+
10+
- Prefer clear, user-facing language: what changed for someone using Canal Study.
11+
- Good: `Keep the keyboard open after adding a card` / `Fix language switching on the Study tab` / `Add daily study reminders`
12+
- Avoid opaque machine style when a plain sentence works: no bare `fix`, `wip`, or unexplained `chore:` unless the change is truly internal (CI-only, deps with no user impact).
13+
- For releases, use something like `Release Canal Study v2.0.2` plus notes that list user-visible changes.
14+
- Release notes are generated from commits between tags—assume every commit may appear in a GitHub Release.

.cursor/rules/public-github-release.mdc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ When the user asks to finish public-repo polish, do the following:
2020
- Prefer GitHub Actions workflow `.github/workflows/release-apk.yml` (**Release Android APK**) to bump, build `Canal-Study.apk`, and publish a GitHub Release
2121
- Release tags must be semver (`v1.2.0`); APK asset name must remain `Canal-Study.apk` for in-app updates
2222
- `expo.extra.updates.githubOwner` / `githubRepo` must match this repository
23+
- When running the workflow, fill the optional **notes** input with short user-facing release notes; GitHub also appends auto-generated notes from commits
24+
- Commit messages must follow `.cursor/rules/commit-messages.mdc` so auto release notes stay readable

.github/workflows/release-apk.yml

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ on:
1414
- major
1515
- none
1616
# "none" = keep current app.json version (already bumped locally)
17+
notes:
18+
description: Optional release notes for users (shown on the GitHub Release page). Leave blank to use auto notes from commits.
19+
required: false
20+
type: string
21+
default: ''
1722

1823
concurrency:
1924
group: release-android
@@ -46,7 +51,7 @@ jobs:
4651
java-version: 17
4752

4853
- name: Setup Android SDK
49-
uses: android-actions/setup-android@v3
54+
uses: android-actions/setup-android@v4
5055

5156
- name: Accept Android licenses
5257
run: yes | sdkmanager --licenses >/dev/null || true
@@ -86,13 +91,28 @@ jobs:
8691
cp android/app/build/outputs/apk/release/app-release.apk dist/Canal-Study.apk
8792
ls -lh dist/Canal-Study.apk
8893
94+
- name: Write release notes file
95+
env:
96+
MANUAL_NOTES: ${{ inputs.notes }}
97+
run: |
98+
{
99+
if [ -n "$MANUAL_NOTES" ]; then
100+
printf '%s\n\n' "$MANUAL_NOTES"
101+
fi
102+
echo "### Install"
103+
echo "Download **Canal-Study.apk** below and install it on your Android device."
104+
echo ""
105+
echo "Version **${{ steps.meta.outputs.version }}** (versionCode ${{ steps.meta.outputs.version_code }})."
106+
} > dist/release-notes.md
107+
cat dist/release-notes.md
108+
89109
- name: Commit version bump and create tag
90110
run: |
91111
git config user.name "github-actions[bot]"
92112
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
93113
git add app.json package.json
94114
if ! git diff --cached --quiet; then
95-
git commit -m "chore: release v${{ steps.meta.outputs.version }}"
115+
git commit -m "Release Canal Study v${{ steps.meta.outputs.version }}"
96116
git push origin HEAD:${{ github.ref_name }}
97117
fi
98118
if git rev-parse "${{ steps.meta.outputs.tag }}" >/dev/null 2>&1; then
@@ -103,10 +123,11 @@ jobs:
103123
fi
104124
105125
- name: Create GitHub Release
106-
uses: softprops/action-gh-release@v2
126+
uses: softprops/action-gh-release@v3
107127
with:
108128
tag_name: ${{ steps.meta.outputs.tag }}
109129
name: Canal Study ${{ steps.meta.outputs.tag }}
130+
body_path: dist/release-notes.md
110131
generate_release_notes: true
111132
files: dist/Canal-Study.apk
112133
fail_on_unmatched_files: true

app.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,15 @@
4242
],
4343
"expo-sqlite",
4444
"expo-font",
45-
"expo-image"
45+
"expo-image",
46+
"@react-native-community/datetimepicker",
47+
[
48+
"expo-notifications",
49+
{
50+
"color": "#E36A2A",
51+
"defaultChannel": "study-reminders"
52+
}
53+
]
4654
],
4755
"extra": {
4856
"updates": {

app/(tabs)/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@ import { GradeButtons } from '@/src/components/GradeButtons';
99
import { ScreenBackground } from '@/src/components/ScreenBackground';
1010
import { SwipeDeck } from '@/src/components/SwipeDeck';
1111
import { deckLabel } from '@/src/db/decks';
12-
import { useDecks } from '@/src/hooks/useDecks';
1312
import { useStudySession } from '@/src/hooks/useStudySession';
1413
import { colors, fonts, spacing } from '@/src/theme';
1514

1615
export default function StudyScreen() {
17-
const { decks, select } = useDecks();
1816
const {
1917
current,
2018
revealed,
2119
loading,
2220
poolSize,
2321
progressLabel,
2422
activeDeck,
23+
decks,
24+
select,
2525
reveal,
2626
grade,
2727
} = useStudySession();
@@ -78,7 +78,7 @@ export default function StudyScreen() {
7878
light
7979
/>
8080
<Animated.View
81-
key={current.card.id + current.mode}
81+
key={current.card.id + current.mode + activeDeck.id}
8282
entering={FadeInDown.duration(320)}
8383
style={styles.stage}
8484
>

app/(tabs)/settings.tsx

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import {
33
Alert,
44
FlatList,
55
Linking,
6+
Platform,
67
Pressable,
78
StyleSheet,
9+
Switch,
810
Text,
911
TextInput,
1012
View,
1113
} from 'react-native';
14+
import DateTimePicker, {
15+
type DateTimePickerEvent,
16+
} from '@react-native-community/datetimepicker';
1217
import * as Application from 'expo-application';
1318
import Constants from 'expo-constants';
1419

@@ -17,6 +22,7 @@ import { ScreenBackground } from '@/src/components/ScreenBackground';
1722
import { Toast } from '@/src/components/Toast';
1823
import { deckLabel } from '@/src/db/decks';
1924
import { useDecks } from '@/src/hooks/useDecks';
25+
import { useStudyReminder } from '@/src/hooks/useStudyReminder';
2026
import { colors, fonts, radius, spacing } from '@/src/theme';
2127
import { useUpdate } from '@/src/updates/UpdateProvider';
2228

@@ -33,11 +39,14 @@ export default function SettingsScreen() {
3339
setDisplayLimit,
3440
} = useDecks();
3541
const { checkForUpdate, localVersion, status } = useUpdate();
42+
const reminder = useStudyReminder();
3643
const [limitValue, setLimitValue] = useState(String(displayLimit));
3744
const [sourceLanguage, setSourceLanguage] = useState('');
3845
const [destinationLanguage, setDestinationLanguage] = useState('');
3946
const [toast, setToast] = useState<string | null>(null);
4047
const [limitSaved, setLimitSaved] = useState(false);
48+
const [showTimePicker, setShowTimePicker] = useState(false);
49+
const [draftTime, setDraftTime] = useState<Date | null>(null);
4150

4251
const buildNumber =
4352
Application.nativeBuildVersion ??
@@ -85,6 +94,78 @@ export default function SettingsScreen() {
8594
void Linking.openURL(GITHUB_REPO_URL);
8695
};
8796

97+
const showPermissionHelp = () => {
98+
Alert.alert(
99+
'Notifications needed',
100+
'Allow notifications for Canal Study in system settings to get daily study reminders.',
101+
[
102+
{ text: 'Not now', style: 'cancel' },
103+
{
104+
text: 'Open settings',
105+
onPress: () => void Linking.openSettings(),
106+
},
107+
]
108+
);
109+
};
110+
111+
const onToggleReminder = async (next: boolean) => {
112+
const result = await reminder.setEnabled(next);
113+
if (!result.ok && result.reason === 'permission') {
114+
showPermissionHelp();
115+
return;
116+
}
117+
setToast(
118+
next
119+
? `Daily reminder set for ${reminder.timeLabel}`
120+
: 'Daily reminder turned off'
121+
);
122+
};
123+
124+
const onReminderTimeChange = (
125+
event: DateTimePickerEvent,
126+
date?: Date
127+
) => {
128+
if (Platform.OS === 'android') {
129+
setShowTimePicker(false);
130+
if (event.type !== 'set' || !date) return;
131+
void applyReminderTime(date);
132+
return;
133+
}
134+
if (date) setDraftTime(date);
135+
};
136+
137+
const applyReminderTime = async (date: Date) => {
138+
const result = await reminder.setTime(date.getHours(), date.getMinutes());
139+
if (!result.ok && result.reason === 'permission') {
140+
showPermissionHelp();
141+
return;
142+
}
143+
const label = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
144+
setToast(`Reminder time set to ${label}`);
145+
};
146+
147+
const openTimePicker = () => {
148+
const date = new Date();
149+
date.setHours(reminder.hour, reminder.minute, 0, 0);
150+
setDraftTime(date);
151+
setShowTimePicker(true);
152+
};
153+
154+
const confirmIosTime = async () => {
155+
setShowTimePicker(false);
156+
if (draftTime) {
157+
await applyReminderTime(draftTime);
158+
}
159+
};
160+
161+
const reminderPickerDate =
162+
draftTime ??
163+
(() => {
164+
const date = new Date();
165+
date.setHours(reminder.hour, reminder.minute, 0, 0);
166+
return date;
167+
})();
168+
88169
const checking = status === 'checking' || status === 'downloading';
89170

90171
return (
@@ -158,7 +239,9 @@ export default function SettingsScreen() {
158239
<Text style={styles.title}>Display limit</Text>
159240
<Text style={styles.copy}>
160241
After a card is shown this many times in the active pair, it
161-
moves to the archive.
242+
moves to the archive. Raising the limit brings archived cards
243+
back if they are still under it; lowering archives cards that
244+
already meet the new limit.
162245
</Text>
163246
<TextInput
164247
value={limitValue}
@@ -173,6 +256,56 @@ export default function SettingsScreen() {
173256
</Pressable>
174257
</View>
175258

259+
{Platform.OS !== 'web' ? (
260+
<View style={styles.card}>
261+
<Text style={styles.title}>Daily reminder</Text>
262+
<Text style={styles.copy}>
263+
Get a local notification each day at the time you choose.
264+
</Text>
265+
<View style={styles.reminderRow}>
266+
<Text style={styles.reminderLabel}>Remind me to study</Text>
267+
<Switch
268+
value={reminder.enabled}
269+
onValueChange={(value) => void onToggleReminder(value)}
270+
disabled={reminder.loading || reminder.busy}
271+
trackColor={{
272+
false: colors.mistDeep,
273+
true: colors.orangeSoft,
274+
}}
275+
thumbColor={
276+
reminder.enabled ? colors.orange : colors.white
277+
}
278+
/>
279+
</View>
280+
<Pressable
281+
onPress={openTimePicker}
282+
disabled={reminder.loading || reminder.busy}
283+
style={styles.secondaryButton}
284+
>
285+
<Text style={styles.secondaryButtonText}>
286+
Reminder time · {reminder.timeLabel}
287+
</Text>
288+
</Pressable>
289+
{showTimePicker ? (
290+
<DateTimePicker
291+
value={reminderPickerDate}
292+
mode="time"
293+
is24Hour
294+
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
295+
onChange={onReminderTimeChange}
296+
/>
297+
) : null}
298+
{Platform.OS === 'ios' && showTimePicker ? (
299+
<Pressable
300+
onPress={() => void confirmIosTime()}
301+
style={styles.button}
302+
>
303+
<Text style={styles.buttonText}>Done</Text>
304+
</Pressable>
305+
) : null}
306+
</View>
307+
) : null}
308+
176309
<Text style={styles.sectionTitle}>Your language pairs</Text>
177310
{decks.length === 0 ? (
178311
<Text style={styles.emptyPairs}>
@@ -294,6 +427,18 @@ const styles = StyleSheet.create({
294427
fontSize: 15,
295428
color: colors.ink,
296429
},
430+
reminderRow: {
431+
flexDirection: 'row',
432+
alignItems: 'center',
433+
justifyContent: 'space-between',
434+
gap: spacing.md,
435+
},
436+
reminderLabel: {
437+
flex: 1,
438+
fontFamily: fonts.bodySemi,
439+
fontSize: 16,
440+
color: colors.ink,
441+
},
297442
sectionTitle: {
298443
fontFamily: fonts.display,
299444
fontSize: 22,

app/_layout.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
1414
import 'react-native-reanimated';
1515

1616
import { initDatabase } from '@/src/db/schema';
17+
import { DecksProvider } from '@/src/hooks/useDecks';
1718
import { colors } from '@/src/theme';
1819
import { UpdateProvider } from '@/src/updates/UpdateProvider';
1920

@@ -51,12 +52,14 @@ export default function RootLayout() {
5152
return (
5253
<GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.mist }}>
5354
<SQLiteProvider databaseName="canal-study.db" onInit={initDatabase}>
54-
<UpdateProvider>
55-
<StatusBar style="dark" />
56-
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.mist } }}>
57-
<Stack.Screen name="(tabs)" />
58-
</Stack>
59-
</UpdateProvider>
55+
<DecksProvider>
56+
<UpdateProvider>
57+
<StatusBar style="dark" />
58+
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.mist } }}>
59+
<Stack.Screen name="(tabs)" />
60+
</Stack>
61+
</UpdateProvider>
62+
</DecksProvider>
6063
</SQLiteProvider>
6164
</GestureHandlerRootView>
6265
);

0 commit comments

Comments
 (0)