Android app with YouTube tutorials, Google Drive materials, Google Forms assignments, and Grok AI sentence correction β full stack implementation guide.
Flutter Android app. Students download APK or install from Play Store. Login with phone + OTP or email/password.
Node.js + Express API on OCI Free VM. Stores all content metadata. Proxies Grok AI calls to protect your key.
React web app. Only you access this. Add YouTube links, Drive PDF links, Google Form URLs from browser.
Students type sentences, backend sends to Grok xAI API, returns corrected sentence + explanation.
| Layer | Technology | Reason |
|---|---|---|
| Android App | Flutter | You already use Flutter. Fast UI, YouTube player package available. |
| Backend API | Node.js + Express | Lightweight, easy Grok API proxy, runs well on OCI Free VM. |
| Database | MySQL | You already use MySQL in RM CMS projects. |
| Admin Panel | React (Vite) | Simple CRUD UI, deploy alongside backend. |
| Auth | JWT + bcrypt | Stateless, works for mobile apps well. |
| Hosting | OCI Always Free VM | Zero cost. You already have this setup. |
| YouTube | youtube_player_flutter | Plays unlisted YouTube videos directly in app. No API key needed for basic playback. |
| AI | Grok xAI API | You already have the API key. |
-- Students table
CREATE TABLE students (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE,
phone VARCHAR(15) UNIQUE,
password VARCHAR(255), -- bcrypt hash
batch VARCHAR(50), -- e.g. 'Batch A - Morning'
is_active TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tutorial videos (YouTube)
CREATE TABLE tutorials (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
description TEXT,
youtube_url VARCHAR(500) NOT NULL, -- full URL or video ID
thumbnail VARCHAR(500), -- optional custom thumbnail
category VARCHAR(100), -- e.g. 'Grammar', 'Pronunciation'
sort_order INT DEFAULT 0,
is_published TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Reading materials (Google Drive links)
CREATE TABLE materials (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
description TEXT,
drive_url VARCHAR(500) NOT NULL,
file_type ENUM('PDF', 'DOC', 'SHEET', 'SLIDE', 'OTHER') DEFAULT 'PDF',
category VARCHAR(100),
sort_order INT DEFAULT 0,
is_published TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Assignments (Google Forms)
CREATE TABLE assignments (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
description TEXT,
form_url VARCHAR(500) NOT NULL, -- Google Form link
due_date DATE,
max_marks INT,
is_published TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- AI correction history (optional, for your records)
CREATE TABLE ai_corrections (
id INT PRIMARY KEY AUTO_INCREMENT,
student_id INT,
original_text TEXT,
corrected_text TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (student_id) REFERENCES students(id)
);
spoken-english-api/
βββ src/
β βββ routes/
β β βββ auth.js # login, register
β β βββ tutorials.js # GET /tutorials
β β βββ materials.js # GET /materials
β β βββ assignments.js # GET /assignments
β β βββ ai.js # POST /ai/correct
β β βββ admin.js # all CRUD for admin panel
β βββ middleware/
β β βββ auth.js # JWT verify middleware
β β βββ adminAuth.js # admin-only middleware
β βββ db.js # MySQL connection pool
β βββ app.js # Express app setup
βββ .env
βββ package.json
βββ server.js
// server.js
const app = require('./src/app');
app.listen(3000, () => console.log('API running on port 3000'));
// src/db.js
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10
});
module.exports = pool;
// .env
DB_HOST=localhost
DB_USER=root
DB_PASS=yourpassword
DB_NAME=spoken_english
JWT_SECRET=your_jwt_secret_here
GROK_API_KEY=your_grok_api_key_here
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=bcrypt_hash_here
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/login | None | Student login β returns JWT |
| GET | /tutorials | JWT | All published tutorials list |
| GET | /materials | JWT | All published materials list |
| GET | /assignments | JWT | All published assignments |
| POST | /ai/correct | JWT | Send sentence β get Grok correction |
| POST | /admin/tutorials | Admin | Add new tutorial |
| PUT | /admin/tutorials/:id | Admin | Edit tutorial |
| DELETE | /admin/tutorials/:id | Admin | Delete tutorial |
| Same CRUD routes for /admin/materials and /admin/assignments | |||
dependencies:
flutter:
sdk: flutter
# HTTP
dio: ^5.4.0
flutter_secure_storage: ^9.0.0 # store JWT safely
# YouTube player
youtube_player_flutter: ^8.1.2 # plays YT videos in-app
# Open Drive links in browser
url_launcher: ^6.2.5
# Open Google Forms in WebView
webview_flutter: ^4.7.0
# State management
provider: ^6.1.2 # or use Riverpod
# UI helpers
cached_network_image: ^3.3.1
shimmer: ^3.0.0 # loading skeleton
lib/
βββ main.dart
βββ core/
β βββ api_client.dart # Dio setup with JWT interceptor
β βββ constants.dart # base URL, colors
βββ models/
β βββ tutorial.dart
β βββ material.dart
β βββ assignment.dart
βββ screens/
β βββ login_screen.dart
β βββ home_screen.dart # bottom nav container
β βββ tutorials/
β β βββ tutorials_list.dart
β β βββ tutorial_player.dart
β βββ materials/
β β βββ materials_list.dart
β β βββ material_viewer.dart # opens drive in-app browser
β βββ assignments/
β β βββ assignments_list.dart
β β βββ assignment_form.dart # webview for Google Form
β βββ ai_correct/
β βββ ai_correct_screen.dart
βββ widgets/
βββ tutorial_card.dart
βββ material_card.dart
βββ assignment_card.dart
// home_screen.dart
final screens = [
TutorialsListScreen(), // Tab 1: π¬ Tutorials
MaterialsListScreen(), // Tab 2: π Materials
AssignmentsListScreen(), // Tab 3: π Assignments
AiCorrectScreen(), // Tab 4: π€ AI Correct
];
BottomNavigationBar(
items: [
BottomNavigationBarItem(icon: Icon(Icons.play_circle), label: 'Tutorials'),
BottomNavigationBarItem(icon: Icon(Icons.menu_book), label: 'Materials'),
BottomNavigationBarItem(icon: Icon(Icons.assignment), label: 'Assignments'),
BottomNavigationBarItem(icon: Icon(Icons.auto_fix_high), label: 'AI Correct'),
],
)
You upload videos to YouTube as Unlisted. The URL is not searchable β only people with the link can watch. You store that URL/video ID in your admin panel. The Flutter app plays it with youtube_player_flutter directly inside the app β no redirect to YouTube app needed.
// tutorial_player.dart
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
class TutorialPlayerScreen extends StatefulWidget {
final Tutorial tutorial;
String extractVideoId(String url) {
// Extracts video ID from full YouTube URL
return YoutubePlayer.convertUrlToId(url) ?? url;
}
@override
_state createState() {
final videoId = extractVideoId(widget.tutorial.youtubeUrl);
final controller = YoutubePlayerController(
initialVideoId: videoId,
flags: const YoutubePlayerFlags(
autoPlay: true,
mute: false,
enableCaption: true,
),
);
// In build():
return YoutubePlayerBuilder(
player: YoutubePlayer(controller: controller),
builder: (context, player) => Scaffold(
appBar: AppBar(title: Text(widget.tutorial.title)),
body: Column(children: [
player,
Padding(
padding: const EdgeInsets.all(16),
child: Text(widget.tutorial.description),
)
]),
),
);
}
}
In your admin panel, you paste the full YouTube URL. The backend stores it. The app extracts the video ID automatically using YoutubePlayer.convertUrlToId().
Supported URL formats: https://youtu.be/VIDEO_ID or https://www.youtube.com/watch?v=VIDEO_ID
You share Google Drive files with "Anyone with the link can view" permission. Paste the share link in admin panel. The app opens the file using one of two strategies:
webview_flutter. Students stay in the app.Recommendation: Use WebView for PDFs β students stay in your app experience.
// material_viewer.dart
import 'package:webview_flutter/webview_flutter.dart';
class MaterialViewerScreen extends StatefulWidget {
final LearningMaterial material;
@override
State createState() {
// Convert share URL to direct viewer URL
// From: https://drive.google.com/file/d/FILE_ID/view?usp=sharing
// To: https://drive.google.com/file/d/FILE_ID/preview
String getPreviewUrl(String driveUrl) {
final uri = Uri.parse(driveUrl);
final pathParts = uri.pathSegments; // ['file','d','FILE_ID','view']
if (pathParts.contains('d')) {
final fileId = pathParts[pathParts.indexOf('d') + 1];
return 'https://drive.google.com/file/d/$fileId/preview';
}
return driveUrl; // fallback
}
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(Uri.parse(getPreviewUrl(material.driveUrl)));
return Scaffold(
appBar: AppBar(title: Text(material.title)),
body: WebViewWidget(controller: controller),
);
}
}
You create a Google Form. Students can submit their responses. You copy the form link from admin panel. The app opens it in a WebView β students complete the form inside the app.
// assignment_form.dart
class AssignmentFormScreen extends StatefulWidget {
final Assignment assignment;
@override
State createState() {
// Replace /edit with /viewform for clean submission URL
String getFormUrl(String url) {
return url
.replaceAll('/edit', '/viewform')
.replaceAll('/closedform', '/viewform');
}
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(Uri.parse(getFormUrl(assignment.formUrl)));
return Scaffold(
appBar: AppBar(title: Text(assignment.title)),
body: WebViewWidget(controller: controller),
);
}
}
The Flutter app sends the student's sentence to YOUR backend. Your backend forwards it to Grok API (with your secret key). Response comes back to app. Key is never exposed to app.
// src/routes/ai.js
const express = require('express');
const axios = require('axios');
const authMiddleware = require('../middleware/auth');
const router = express.Router();
const db = require('../db');
router.post('/correct', authMiddleware, async (req, res) => {
const { sentence } = req.body;
if (!sentence || sentence.trim().length === 0) {
return res.status(400).json({ error: 'Sentence is required' });
}
const prompt = `You are an English teacher. A student wrote:
"${sentence}"
Please:
1. Correct any grammar, spelling, or punctuation errors.
2. Provide the corrected sentence.
3. Explain each mistake briefly in simple English.
4. Give 1-2 better alternative ways to say the same thing.
Format your response as JSON:
{
"corrected": "...",
"mistakes": ["mistake 1", "mistake 2"],
"alternatives": ["alt 1", "alt 2"]
}`;
try {
const response = await axios.post(
'https://api.x.ai/v1/chat/completions',
{
model: 'grok-3',
messages: [{ role: 'user', content: prompt }],
temperature: 0.3,
max_tokens: 500,
},
{
headers: {
'Authorization': `Bearer ${process.env.GROK_API_KEY}`,
'Content-Type': 'application/json',
},
}
);
const content = response.data.choices[0].message.content;
const cleanJson = content
.replace(/```json/g, '')
.replace(/```/g, '')
.trim();
const parsed = JSON.parse(cleanJson);
// Save to history (optional)
await db.execute(
'INSERT INTO ai_corrections (student_id, original_text, corrected_text) VALUES (?,?,?)',
[req.studentId, sentence, parsed.corrected]
);
res.json(parsed);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'AI correction failed' });
}
});
module.exports = router;
// ai_correct_screen.dart
class AiCorrectScreen extends StatefulWidget {
final controller = TextEditingController();
Map<String, dynamic>? result;
bool isLoading = false;
Future<void> correctSentence() async {
setState(() => isLoading = true);
final response = await ApiClient.instance.post('/ai/correct', {
'sentence': controller.text
});
setState(() {
result = response.data;
isLoading = false;
});
}
// UI in build():
Column(children: [
TextField(
controller: controller,
maxLines: 4,
decoration: InputDecoration(
hintText: 'Type your English sentence here...',
border: OutlineInputBorder(),
),
),
ElevatedButton(
onPressed: correctSentence,
child: Text('Check & Correct'),
),
if (result != null) ...[
Card(child: Column(children: [
Text('β
Corrected: ${result!["corrected"]}'),
Text('π Mistakes:'),
...(result!['mistakes'] as List)
.map((m) => Text('β’ $m')),
Text('π‘ Alternatives:'),
...(result!['alternatives'] as List)
.map((a) => Text('β $a')),
])),
]
])
}
// adminAuth.js middleware
module.exports = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.isAdmin) {
return res.status(403).json({ error: 'Admin access only' });
}
next();
};
// Admin login route β no DB needed, use env vars
router.post('/admin/login', (req, res) => {
const { username, password } = req.body;
if (
username === process.env.ADMIN_USERNAME &&
bcrypt.compareSync(password, process.env.ADMIN_PASSWORD_HASH)
) {
const token = jwt.sign(
{ isAdmin: true },
process.env.JWT_SECRET,
{ expiresIn: '12h' }
);
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// POST /auth/login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const [rows] = await db.execute(
'SELECT * FROM students WHERE email = ? AND is_active = 1',
[email]
);
if (!rows.length || !bcrypt.compareSync(password, rows[0].password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const student = rows[0];
const token = jwt.sign(
{ studentId: student.id, name: student.name },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);
res.json({ token, name: student.name });
});
// JWT auth middleware
// src/middleware/auth.js
module.exports = (req, res, next) => {
try {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.studentId = decoded.studentId;
next();
} catch {
res.status(401).json({ error: 'Unauthorized' });
}
};
You have two options for how students get accounts:
| Service | What You Use | Cost | Notes |
|---|---|---|---|
| OCI Always Free VM | Node.js backend + MySQL + Admin panel hosting | βΉ0 | You already have this. 2 OCPU, 12GB RAM. |
| YouTube | Unlisted video hosting | βΉ0 | Free for uploading. No API key needed for player. |
| Google Drive | PDF/material hosting | βΉ0 (up to 15GB) | Free personal Google Drive is enough for materials. |
| Google Forms | Assignment submissions | βΉ0 | Completely free. Results in Google Sheets. |
| Grok xAI API | Sentence correction module | ~βΉ0.2β0.5/call | Grok-3: $3 per 1M input tokens. Each call ~200β500 tokens. 100 calls/day β $1β3/month β βΉ100β250. |
| Domain (optional) | yourappname.com for admin panel | βΉ700β1200/year | Optional. Can use server IP directly if budget is tight. |
| SSL Certificate | HTTPS for your API | βΉ0 | Use Let's Encrypt via Certbot. Free and auto-renews. |
| Total (without Play Store) | ~βΉ100β300/month | Only Grok API is a recurring cost. | |
| Item | Cost | Notes |
|---|---|---|
| Google Play Developer Account | $25 (β βΉ2,100) | One-time lifetime fee. You can publish unlimited apps. |
| App signing (keystore) | βΉ0 | Use Flutter's keytool to generate. Keep backup of keystore! |
| APK review time | β | 3β7 days for first submission. Updates usually 1β2 days. |
flutter build apk --release and share it directly via WhatsApp or Google Drive link. Students enable "Install from unknown sources" and install. No βΉ2,100 needed. Only use Play Store if you plan to grow to public users.
| What You Need | Required? | How to Get | Cost |
|---|---|---|---|
| YouTube Data API | NOT needed | The youtube_player_flutter package uses the standard YouTube player β no API key needed for playing unlisted videos in Flutter. |
βΉ0 |
| Google Drive API | NOT needed | You open Drive share links in a WebView. No API integration required. No key, no quota. | βΉ0 |
| Google Forms API | NOT needed | You open Form URLs in WebView. Students submit directly on the Form page. Google Sheets records responses. | βΉ0 |
| Google Play Developer | IF Play Store | Register at play.google.com/console. Need a Google account + one-time fee. | $25 once |
| Android App Permissions | In app manifest | Add INTERNET permission in AndroidManifest.xml. No special Google permission needed. | βΉ0 |
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- For opening Drive/Forms in WebView -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:usesCleartextTraffic="true" <!-- only if API is HTTP not HTTPS -->
android:networkSecurityConfig="@xml/network_security_config"
...
>
</manifest>
Create MySQL schema, set up Node.js project, implement all routes with JWT auth, test with Postman.
CRUD pages for tutorials, materials, assignments. Simple login. Deploy alongside backend on OCI VM.
Login screen, JWT storage, bottom nav, API client with interceptor.
YouTube player integration, Drive WebView, Forms WebView.
Backend proxy route + Flutter UI with result display.
Test on real device, build release APK, share via WhatsApp or upload to Play Store.