Switch coach experience to English only
This commit is contained in:
76
README.md
76
README.md
@@ -1,19 +1,19 @@
|
|||||||
# English AI Coach
|
# English AI Coach
|
||||||
|
|
||||||
English AI Coach ist ein Starterprojekt fuer eine mobile App zum Englischlernen mit KI. Die App hilft Nutzern, kurze englische Saetze zu schreiben, Fehler zu erkennen und Korrekturen mit deutscher Erklaerung zu erhalten.
|
English AI Coach is a starter project for a mobile AI-based English learning app. It helps users write short English sentences, find mistakes, and get clear corrections in English.
|
||||||
|
|
||||||
Das Projekt ist bewusst schlank gehalten: React Native, Expo, TypeScript und React Navigation. Es nutzt keine unnoetigen Expo-only Libraries und ist so vorbereitet, dass spaeter lokale KI ueber Ollama, Speech-to-Text, Text-to-Speech und Push Notifications ergaenzt werden koennen.
|
The project is intentionally lightweight: React Native, Expo, TypeScript, and React Navigation. It avoids unnecessary Expo-only libraries and is prepared for local AI with Ollama, speech input, text-to-speech, and push notifications.
|
||||||
|
|
||||||
## Features MVP
|
## Features MVP
|
||||||
|
|
||||||
- HomeScreen mit Fortschrittskarte und Mock Daily Reminder
|
- HomeScreen with progress card and mock daily reminder
|
||||||
- Level-Auswahl fuer A1, A2, B1 und B2
|
- Level selection for A1, A2, B1, and B2
|
||||||
- ChatScreen mit einfacher User- und AI-Nachrichtenansicht
|
- ChatScreen with user and AI messages
|
||||||
- AI-Antworten koennen auf dem iPhone vorgelesen werden
|
- AI replies can be read aloud on iPhone
|
||||||
- Mock KI-Service fuer Beispielkorrekturen
|
- Ollama integration with offline fallback examples
|
||||||
- TypeScript-typisierte Navigation
|
- TypeScript-typed navigation
|
||||||
- Struktur fuer spaetere Services, Hooks und Utilities
|
- Structure for future services, hooks, and utilities
|
||||||
- Web/PWA-Ausbau vorbereitet ueber Expo Web
|
- Web/PWA path prepared through Expo Web
|
||||||
|
|
||||||
## Beispiel
|
## Beispiel
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ User: I go yesterday to work
|
|||||||
|
|
||||||
AI Coach:
|
AI Coach:
|
||||||
Good try. Correct: "I went to work yesterday."
|
Good try. Correct: "I went to work yesterday."
|
||||||
"Yesterday" braucht Past Tense.
|
Use the past tense with "yesterday".
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -31,11 +31,11 @@ Good try. Correct: "I went to work yesterday."
|
|||||||
- Expo
|
- Expo
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- React Navigation
|
- React Navigation
|
||||||
- Geplant: Ollama API fuer lokale KI
|
- Ollama API for local AI
|
||||||
- Geplant: Speech-to-Text und Text-to-Speech
|
- Text-to-speech with `expo-speech`
|
||||||
- Text-to-Speech mit `expo-speech`
|
- Speech input currently through iPhone keyboard dictation
|
||||||
- Spracheingabe aktuell ueber iPhone-Diktierfunktion in der Tastatur
|
- Planned: in-app speech-to-text
|
||||||
- Geplant: Push Notifications fuer Daily Reminder
|
- Planned: push notifications for daily reminders
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
@@ -69,56 +69,56 @@ english-ai-coach/
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Voraussetzungen:
|
Requirements:
|
||||||
|
|
||||||
- Node.js
|
- Node.js
|
||||||
- npm
|
- npm
|
||||||
- Expo ueber `npx expo`
|
- Expo through `npx expo`
|
||||||
|
|
||||||
Abhaengigkeiten installieren:
|
Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionale lokale KI-Konfiguration anlegen:
|
Create optional local AI configuration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
In `.env` kannst du dein Ollama-Modell eintragen:
|
Set your Ollama model in `.env`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
EXPO_PUBLIC_OLLAMA_BASE_URL=http://localhost:11434
|
EXPO_PUBLIC_OLLAMA_BASE_URL=http://localhost:11434
|
||||||
EXPO_PUBLIC_OLLAMA_MODEL=llama3.2
|
EXPO_PUBLIC_OLLAMA_MODEL=llama3.2
|
||||||
```
|
```
|
||||||
|
|
||||||
Wenn du die App auf einem echten Smartphone mit Expo Go testest, ist `localhost` das Smartphone selbst. Nutze dann die IP deines PCs, zum Beispiel:
|
When testing on a real phone with Expo Go, `localhost` means the phone itself. Use your laptop IP instead, for example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
EXPO_PUBLIC_OLLAMA_BASE_URL=http://192.168.10.102:11434
|
EXPO_PUBLIC_OLLAMA_BASE_URL=http://192.168.10.102:11434
|
||||||
EXPO_PUBLIC_OLLAMA_MODEL=qwen2.5:7b
|
EXPO_PUBLIC_OLLAMA_MODEL=qwen2.5:7b
|
||||||
```
|
```
|
||||||
|
|
||||||
App starten:
|
Start the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Spracheingabe und Vorlesen
|
## Speech Input And Playback
|
||||||
|
|
||||||
Vorlesen funktioniert direkt in Expo Go. AI-Antworten werden automatisch ueber den iPhone-Lautsprecher vorgelesen. Im Chat gibt es ausserdem Buttons fuer `Letzte Antwort vorlesen` und `Stop`.
|
Playback works directly in Expo Go. AI replies are read aloud through the iPhone speaker. The chat also has buttons for `Read last answer` and `Stop`.
|
||||||
|
|
||||||
Spracheingabe funktioniert aktuell ueber die iPhone-Tastatur:
|
Speech input currently works through the iPhone keyboard:
|
||||||
|
|
||||||
- Chat-Eingabefeld antippen
|
- Tap the chat input field
|
||||||
- Mikrofon-Symbol auf der iPhone-Tastatur druecken
|
- Press the microphone icon on the iPhone keyboard
|
||||||
- Englisch sprechen
|
- Speak English
|
||||||
- Text pruefen und senden
|
- Review the text and send it
|
||||||
|
|
||||||
Echtes In-App Speech-to-Text mit eigenem Mikrofon-Button ist fuer spaeter geplant. Dafuer braucht die App entweder ein natives iOS-Modul nach `expo prebuild` oder eine Transkriptions-API wie Whisper.
|
Real in-app speech-to-text with a custom microphone button is planned for later. That requires either a native iOS module after `expo prebuild` or a transcription API such as Whisper.
|
||||||
|
|
||||||
iOS Preview starten:
|
iOS Preview starten:
|
||||||
|
|
||||||
@@ -132,23 +132,23 @@ Web Preview starten:
|
|||||||
npm run web
|
npm run web
|
||||||
```
|
```
|
||||||
|
|
||||||
Hinweis: Fuer lokale iOS-Simulator-Builds wird macOS mit Xcode benoetigt. Mit Expo Go kann die App auf einem echten Geraet getestet werden.
|
Note: local iOS simulator builds require macOS with Xcode. With Expo Go, the app can be tested on a real device.
|
||||||
|
|
||||||
## Ollama Integration
|
## Ollama Integration
|
||||||
|
|
||||||
Der Chat nutzt `src/services/ollamaClient.ts` fuer echte lokale KI-Antworten. Falls Ollama nicht erreichbar ist, faellt `src/services/mockAiCoach.ts` automatisch auf Beispielantworten zurueck. Die Modell-Konfiguration liegt in `src/config/ai.ts` und liest Werte aus `.env`.
|
The chat uses `src/services/ollamaClient.ts` for real local AI replies. If Ollama is not reachable, `src/services/mockAiCoach.ts` automatically falls back to example replies. Model configuration lives in `src/config/ai.ts` and reads values from `.env`.
|
||||||
|
|
||||||
Moeglicher lokaler Ablauf:
|
Local flow:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
React Native Chat UI
|
React Native Chat UI
|
||||||
-> AI Service
|
-> AI Service
|
||||||
-> lokale Ollama API
|
-> local Ollama API
|
||||||
-> Modell wie llama, qwen oder mistral
|
-> model such as llama, qwen, or mistral
|
||||||
-> Antwort mit Korrektur und Erklaerung
|
-> English-only correction and explanation
|
||||||
```
|
```
|
||||||
|
|
||||||
Fuer eine PWA oder lokale Web-Version kann die App spaeter im Browser laufen und mit einem lokalen Ollama-Server im Netzwerk verbunden werden.
|
For a PWA or local web version, the app can later run in the browser and connect to a local Ollama server on the network.
|
||||||
|
|
||||||
## PWA Idee
|
## PWA Idee
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ChatBubble({ message }: ChatBubbleProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.wrapper, isUser ? styles.userWrapper : styles.aiWrapper]}>
|
<View style={[styles.wrapper, isUser ? styles.userWrapper : styles.aiWrapper]}>
|
||||||
<View style={[styles.bubble, isUser ? styles.userBubble : styles.aiBubble]}>
|
<View style={[styles.bubble, isUser ? styles.userBubble : styles.aiBubble]}>
|
||||||
<Text style={styles.sender}>{isUser ? 'Du' : 'AI Coach'}</Text>
|
<Text style={styles.sender}>{isUser ? 'You' : 'AI Coach'}</Text>
|
||||||
<Text style={styles.text}>{message.text}</Text>
|
<Text style={styles.text}>{message.text}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ export function ProgressCard() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.label}>Heute gelernt</Text>
|
<Text style={styles.label}>Practice today</Text>
|
||||||
<Text style={styles.value}>12 min</Text>
|
<Text style={styles.value}>12 min</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.track}>
|
<View style={styles.track}>
|
||||||
<View style={styles.fill} />
|
<View style={styles.fill} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.hint}>Daily Reminder: 18:30 Uhr, 10 Minuten Englisch ueben.</Text>
|
<Text style={styles.hint}>Daily Reminder: 6:30 PM, 10 minutes of English practice.</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ export function useDailyReminder() {
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
time: '18:30',
|
time: '18:30',
|
||||||
label: 'Taegliche Uebung ist vorbereitet',
|
label: 'Daily practice is ready',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function AppNavigator() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'English AI Coach' }} />
|
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'English AI Coach' }} />
|
||||||
<Stack.Screen name="Level" component={LevelScreen} options={{ title: 'Level auswaehlen' }} />
|
<Stack.Screen name="Level" component={LevelScreen} options={{ title: 'Choose level' }} />
|
||||||
<Stack.Screen name="Chat" component={ChatScreen} options={{ title: 'AI Chat' }} />
|
<Stack.Screen name="Chat" component={ChatScreen} options={{ title: 'AI Chat' }} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function ChatScreen({ route }: ChatScreenProps) {
|
|||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
text: `Willkommen! Dein Level ist ${level}. Schreib einen englischen Satz und ich korrigiere ihn.`,
|
text: `Welcome. Your level is ${level}. Write an English sentence and I will correct it.`,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
@@ -34,7 +34,7 @@ export function ChatScreen({ route }: ChatScreenProps) {
|
|||||||
setInput('');
|
setInput('');
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
|
|
||||||
// This mock can later be replaced by Ollama or another AI API client.
|
// Uses Ollama when available and falls back to local examples when offline.
|
||||||
const reply = await getMockAiReply(userMessage.text, level);
|
const reply = await getMockAiReply(userMessage.text, level);
|
||||||
setMessages((current) => [...current, { id: `ai-${Date.now()}`, role: 'assistant', text: reply }]);
|
setMessages((current) => [...current, { id: `ai-${Date.now()}`, role: 'assistant', text: reply }]);
|
||||||
speakText(reply);
|
speakText(reply);
|
||||||
@@ -59,21 +59,21 @@ export function ChatScreen({ route }: ChatScreenProps) {
|
|||||||
<TextInput
|
<TextInput
|
||||||
multiline
|
multiline
|
||||||
onChangeText={setInput}
|
onChangeText={setInput}
|
||||||
placeholder="Schreib einen englischen Satz..."
|
placeholder="Write an English sentence..."
|
||||||
placeholderTextColor={colors.textSecondary}
|
placeholderTextColor={colors.textSecondary}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={input}
|
value={input}
|
||||||
/>
|
/>
|
||||||
<View style={styles.actionRow}>
|
<View style={styles.actionRow}>
|
||||||
<View style={styles.actionItem}>
|
<View style={styles.actionItem}>
|
||||||
<PrimaryButton label={isSending ? 'Denke...' : 'Senden'} onPress={sendMessage} />
|
<PrimaryButton label={isSending ? 'Thinking...' : 'Send'} onPress={sendMessage} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actionItem}>
|
<View style={styles.actionItem}>
|
||||||
<PrimaryButton label="Stop" variant="secondary" onPress={stopSpeaking} />
|
<PrimaryButton label="Stop" variant="secondary" onPress={stopSpeaking} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
label="Letzte Antwort vorlesen"
|
label="Read last answer"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const lastAiMessage = [...messages].reverse().find((message) => message.role === 'assistant');
|
const lastAiMessage = [...messages].reverse().find((message) => message.role === 'assistant');
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export function HomeScreen({ navigation }: HomeScreenProps) {
|
|||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<View style={styles.hero}>
|
<View style={styles.hero}>
|
||||||
<Text style={styles.eyebrow}>Personal English Tutor</Text>
|
<Text style={styles.eyebrow}>Personal English Tutor</Text>
|
||||||
<Text style={styles.title}>Lerne Englisch mit einem KI Coach</Text>
|
<Text style={styles.title}>Practice English with an AI teacher</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Starte mit deinem Level, schreibe kurze Saetze und erhalte Korrekturen mit deutscher Erklaerung.
|
Choose your level, write short sentences, and get clear corrections in English.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -29,10 +29,10 @@ export function HomeScreen({ navigation }: HomeScreenProps) {
|
|||||||
<View style={styles.reminderCard}>
|
<View style={styles.reminderCard}>
|
||||||
<Text style={styles.cardTitle}>Daily Reminder</Text>
|
<Text style={styles.cardTitle}>Daily Reminder</Text>
|
||||||
<Text style={styles.cardText}>{reminder.label}</Text>
|
<Text style={styles.cardText}>{reminder.label}</Text>
|
||||||
<Text style={styles.cardMeta}>{reminder.enabled ? reminder.time : 'Aus'}</Text>
|
<Text style={styles.cardMeta}>{reminder.enabled ? reminder.time : 'Off'}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<PrimaryButton label="Level waehlen" onPress={() => navigation.navigate('Level')} />
|
<PrimaryButton label="Choose level" onPress={() => navigation.navigate('Level')} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export function LevelScreen({ navigation }: LevelScreenProps) {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
|
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.title}>Welches Englisch-Level passt zu dir?</Text>
|
<Text style={styles.title}>Which English level fits you?</Text>
|
||||||
<Text style={styles.subtitle}>Du kannst spaeter jederzeit wechseln. Das Level steuert die Mock-Antworten.</Text>
|
<Text style={styles.subtitle}>You can change it later. The level controls how simple the coach answers.</Text>
|
||||||
|
|
||||||
{levels.map((item) => (
|
{levels.map((item) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import { aiConfig } from '../config/ai';
|
|||||||
import { askOllama } from './ollamaClient';
|
import { askOllama } from './ollamaClient';
|
||||||
|
|
||||||
const examples: Record<EnglishLevel, string> = {
|
const examples: Record<EnglishLevel, string> = {
|
||||||
A1: 'Fast richtig! Correct: "I am learning English." Warum? Bei "I" nutzt du "am".',
|
A1: 'Good try! Correct: "I am learning English." Use "am" with "I" in the present continuous.',
|
||||||
A2: 'Good try. Correct: "I went to work yesterday." "Yesterday" braucht Past Tense.',
|
A2: 'Good try. Correct: "I went to work yesterday." Use the past tense with "yesterday".',
|
||||||
B1: 'Nice sentence. Alternative: "I have been practicing every day." Das klingt natuerlicher.',
|
B1: 'Nice sentence. Alternative: "I have been practicing every day." This sounds more natural.',
|
||||||
B2: 'Strong idea. Improve flow: use linking words like "however", "therefore" or "in addition".',
|
B2: 'Strong idea. Improve flow: use linking words like "however", "therefore" or "in addition".',
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getMockAiReply(input: string, level: EnglishLevel) {
|
export async function getMockAiReply(input: string, level: EnglishLevel) {
|
||||||
if (!input.trim()) {
|
if (!input.trim()) {
|
||||||
return 'Schreib einen kurzen englischen Satz, dann korrigiere ich ihn fuer dein Level.';
|
return 'Write a short English sentence and I will correct it for your level.';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -20,5 +20,5 @@ export async function getMockAiReply(input: string, level: EnglishLevel) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${examples[level]}\n\nDein Satz: "${input.trim()}"\n\nKI Backend vorbereitet: ${aiConfig.ollamaModel}`;
|
return `${examples[level]}\n\nYour sentence: "${input.trim()}"\n\nAI backend: ${aiConfig.ollamaModel}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,19 @@ export async function askOllama(input: string, level: EnglishLevel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as OllamaGenerateResponse;
|
const data = (await response.json()) as OllamaGenerateResponse;
|
||||||
return data.response?.trim() || 'Ich konnte keine Antwort erzeugen.';
|
return data.response?.trim() || 'I could not generate a useful answer.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrompt(input: string, level: EnglishLevel) {
|
function buildPrompt(input: string, level: EnglishLevel) {
|
||||||
return `Du bist ein freundlicher Englisch-Coach fuer deutsche Lernende.
|
return `You are a friendly English teacher and conversation coach.
|
||||||
Level: ${level}
|
Level: ${level}
|
||||||
Aufgabe:
|
Rules:
|
||||||
1. Korrigiere den englischen Satz.
|
1. Reply only in English.
|
||||||
2. Erklaere den wichtigsten Fehler kurz auf Deutsch.
|
2. Correct the user's sentence.
|
||||||
3. Gib eine bessere natuerliche Variante.
|
3. Explain the main mistake briefly in simple English.
|
||||||
4. Antworte kurz und motivierend.
|
4. Give one natural alternative sentence.
|
||||||
|
5. Keep the answer short, clear, and encouraging.
|
||||||
|
6. Do not use German.
|
||||||
|
|
||||||
Satz des Nutzers: ${input}`;
|
User sentence: ${input}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { EnglishLevel } from '../navigation/types';
|
import type { EnglishLevel } from '../navigation/types';
|
||||||
|
|
||||||
export const levels: Array<{ level: EnglishLevel; title: string; description: string }> = [
|
export const levels: Array<{ level: EnglishLevel; title: string; description: string }> = [
|
||||||
{ level: 'A1', title: 'Beginner', description: 'Einfache Saetze, Alltag und Grundlagen.' },
|
{ level: 'A1', title: 'Beginner', description: 'Simple sentences, daily life, and basics.' },
|
||||||
{ level: 'A2', title: 'Elementary', description: 'Vergangenheit, Fragen und kurze Dialoge.' },
|
{ level: 'A2', title: 'Elementary', description: 'Past tense, questions, and short conversations.' },
|
||||||
{ level: 'B1', title: 'Intermediate', description: 'Freies Schreiben mit klaren Korrekturen.' },
|
{ level: 'B1', title: 'Intermediate', description: 'Free writing with clear corrections.' },
|
||||||
{ level: 'B2', title: 'Upper Intermediate', description: 'Natuerlicher Ausdruck und bessere Struktur.' },
|
{ level: 'B2', title: 'Upper Intermediate', description: 'Natural expression and better structure.' },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user