diff --git a/.gitignore b/.gitignore index 353c2ce..783ffbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -*.db + __pycache__/ *.log .env diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 797d989..87b2916 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -30,6 +30,12 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index 95e107e..bf5454b 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,279 @@ -# Find-My-Ride -[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org) +# Find My Ride – Kotlin Multiplatform Ride-Sharing App +### Drexel University – CS 461 Final Project +### Built by: +- Mustafa Bookwala +- Samii Shabuse +- Kennan Lu -# Coding Standards +--- +## Overview -## Commit Messages +**Find My Ride** is a full-stack **Kotlin Multiplatform (KMP)** mobile application that simulates a Drexel-focused ride-sharing service. +The app allows students to: -Following the committing convention of [Conventional Commits](conventionalcommits.org/en/) +- Create an account & log in +- View their profile +- Browse available ride offers +- Publish a ride offer as a driver +- Send messages to other users +- Store all data in a pre-loaded SQLite database -# Authors -- Mustafa Bookwala -- Samii Shabuse -- Kennan Lu +The purpose of the project is to demonstrate **end-to-end database integration**, clean architecture, multiplatform UI, and CRUD operations learned in CS461. + +--- + +## Tech Stack + +### **Frontend / App UI** +- **Kotlin Multiplatform (KMP)** +- **JetBrains Compose Multiplatform** +- **Material3 Components** +- **Navigation built manually via sealed classes** + +### **Database Layer** +- **SQLite** (preloaded DB file in `assets/findmyride.db`) +- **Custom repository layer** for all DB tables +- **Android SQLiteOpenHelper** for managing the database (Android-only) +- **Raw SQL queries** (no ORM) for maximum clarity + +### **Platforms Supported** +- **Android** (Primary target) +- **Compose Desktop** (Compilation supported, no DB) +- KMP structure prepared for iOS/JS, but **DB only implemented on Android** for this assignment. + +--- + +## Project Structure +```SQL +composeApp/ +├── src/ +│ ├── commonMain/ +│ │ ├── feature/ +│ │ │ ├── auth/ # Login, Registration +│ │ │ ├── profile/ # User profile screen +│ │ │ ├── rides/ # Find Ride & Offer Ride +│ │ │ ├── messages/ # Messaging system +│ │ │ └── db/ # Repository interfaces & shared data models +│ │ ├── App.kt # Root-level navigation deciding Login/Main +│ │ └── MainRoute.kt # In-app navigation (Dashboard → Screens) +│ │ +│ ├── androidMain/ +│ │ ├── db/ +│ │ │ ├── FindMyRideDbProvider.kt # Loads SQLite DB from assets +│ │ │ ├── AndroidAuthRepository.kt # USER table +│ │ │ ├── AndroidProfileRepository.kt # USER + VEHICLE +│ │ │ ├── AndroidMessagesRepository.kt # MESSAGE table +│ │ │ └── AndroidRideRepository.kt # RIDE_OFFER + RIDE_REQUEST +│ │ └── MainActivity.kt # Android entry point +│ │ +│ └── iosMain/ # (Not used) +│ +└── assets/ +└── findmyride.db # Preloaded SQLite database +``` + + +--- + +## Database Schema Overview + +The app uses a **pre-populated SQLite database** stored in `assets/findmyride.db`. +This database contains all tables required for the ride-sharing scenario: + +### **USER** +| Column | Type | Description | +|--------|------|-------------| +| user_id | INTEGER PK | Unique user ID | +| email | TEXT | Login credential | +| password | TEXT | User password | +| display_name | TEXT | Display name | +| phone | TEXT | User contact number | + +### **VEHICLE** +| Column | Type | +|--------|------| +| vehicle_id | INTEGER PK | +| owner_user_id | INTEGER FK → USER | +| make | TEXT | +| model | TEXT | +| color | TEXT | + +### **LOCATION** +Preset Drexel locations: +- University Crossings +- Korman Center +- Main Building +- etc. + +### **RIDE_OFFER** +Driver-created ride offers +(Read by AvailableRidesScreen, written by AvailableOfferScreen) + +| Column | Type | +|--------|------| +| offer_id | INTEGER PK | +| driver_id | INTEGER FK → USER | +| vehicle_id | INTEGER FK → VEHICLE | +| original_location_id | INTEGER FK → LOCATION | +| dest_location_id | INTEGER FK → LOCATION | +| depart_at | TEXT | +| seats_available | INTEGER | +| price_base | REAL | +| price_per_mile | REAL | +| status | TEXT | + +### **RIDE_REQUEST** +Stores ride requests created by riders +(Used by repository, not fully exposed in UI) + +### **MESSAGE** +Messaging between users +| Column | Type | +|--------|------| +| message_id | INTEGER PK | +| sender_id | INTEGER FK → USER | +| receiver_id | INTEGER | +| content | TEXT | +| timestamp | TEXT | + +--- + +## Database Architecture & Access Layer + +This project uses a clean Repository Pattern to keep all SQLite logic isolated to the Android layer while allowing the UI layer (commonMain) to stay fully multiplatform. + +1. FindMyRideDbProvider (Android-only) + +- Loads the SQLite database file (findmyride.db) from the assets folder. +- Copies it into Android internal storage on first launch. +- Exposes functions for obtaining readable and writable database instances. +- Ensures all repositories use the same initialized database. + +Located at: androidMain/db/FindMyRideDbProvider.kt + + +2. Repository Interfaces (commonMain) + +Each feature defines an interface that represents its database API. These interfaces are platform-agnostic. + +Example: RideRepository defines functions like: +- getOpenRideOffers() +- createRideOffer(...) + +The UI talks ONLY to these interfaces, never SQLite directly. + + +3. Android Implementations (androidMain) + +Each repository interface has a real implementation in androidMain that uses: +- SQLiteDatabase +- rawQuery() +- insert() +- update() +- delete() + +Examples: +- AndroidRideRepository reads/writes RIDE_OFFER and RIDE_REQUEST +- AndroidAuthRepository reads USER for login +- AndroidProfileRepository reads USER + VEHICLE +- AndroidMessagesRepository reads/writes MESSAGE + +This keeps SQL isolated to Android while UI stays pure KMP. -## Database Setup +4. UI Layer (commonMain) Consumes Repositories -1. Install SQLite if you don’t have it (`sqlite3 --version` to check). +The UI never touches SQLite. + +Examples: +- Reading from DB: rideRepository.getOpenRideOffers() +- Writing to DB: rideRepository.createRideOffer(...) + +This separation ensures portability and clean architecture. + + +------------------------------------------------------------- + +## Feature-by-Feature Explanation + +1. Login Screen +- Reads from USER through AuthRepository. +- Validates email + password. +- Stores currentUserId inside MainRoute. +- On success -> navigates to Dashboard. + + +2. Dashboard + Acts as the main navigation hub for: +- Profile +- Find Ride +- Offer Ride +- Messages + +No DB calls here. + + +3. AvailableRidesScreen — Database READ +- Uses rideRepository.getOpenRideOffers(). +- Shows list of all open rides from RIDE_OFFER table. +- Displays origin, destination, seats, price, driver info. +- Splits results into “Best Matches” and “Other Matches”. + +Backend: AndroidRideRepository using SELECT queries. + + +4. AvailableOfferScreen — Database WRITE + This screen lets a driver publish a ride. + +When user taps Publish: +- createRideOffer() is called with driverId, vehicleId, locations, pricing, etc. +- This performs INSERT INTO RIDE_OFFER. +- App returns to Dashboard afterward. + + +5. Profile Screen — Database READ + Reads from USER and VEHICLE: +- display_name +- email +- phone +- car make/model/color + +Used to show account info. + + +6. Messages Screen — Database READ & WRITE +- Reads conversations via SELECT on MESSAGE table. +- Displays messages in a thread-style list. +- Sends messages using INSERT INTO MESSAGE. +- Powered by MessagesRepository and AndroidMessagesRepository. + + +------------------------------------------------------------- + +## How To Run the Project + +Requirements: +- Android Studio (Ladybug / Koala / modern KMP version) +- Kotlin Multiplatform plugin enabled +- Android SDK 34 or higher + +Steps: +1. Open the project in Android Studio. +2. Wait for Gradle sync to complete. +3. Select run configuration: composeApp:androidApp +4. Run on emulator or Android device. +5. App starts and automatically copies SQLite database from assets. + +No server required. +No external API required. +Database is bundled inside the APK. + +------------------------------------------------------------- + +## How To Create A New Database + +1. Install SQLite if you don’t have it (`sqlite3 --version` to check). - Using Version: 3.50.4 2025-07-30 19:33:53 4d8adfb30e03f9cf27f800a2c1ba3c48fb4ca1b08b0f5ed59a4d5ecbf45e20a3 (64-bit) 2. In the root project folder, run: ```bash @@ -27,3 +284,31 @@ sqlite3 findmyride.db < database/schema.sql sqlite3 findmyride.db sqlite> .tables ``` + +------------------------------------------------------------- + +## Key Implementation Notes + +- UI state uses Jetpack Compose’s remember { mutableStateOf() }. +- Navigation uses sealed classes (RootScreen, HomePage). +- UI + Repository Interfaces live in commonMain. +- SQLite logic lives exclusively in androidMain. +- Database access uses raw SQL for transparency (no ORM). +- Database lifecycle & copying handled by FindMyRideDbProvider. + + +------------------------------------------------------------- + +## Future Improvements + +- Add ride filtering by time, price, seats. +- Add vehicle picker tied to VEHICLE table. +- Add a full chat UI per ride. +- Support for iOS using SQLDelight or KMP-SQLite. +- Add push-style notifications for new rides. +- Implement ride request matching algorithm. + + +------------------------------------------------------------- + + diff --git a/app/composeApp/src/androidMain/AndroidManifest.xml b/app/composeApp/src/androidMain/AndroidManifest.xml index cdba621..d973028 100644 --- a/app/composeApp/src/androidMain/AndroidManifest.xml +++ b/app/composeApp/src/androidMain/AndroidManifest.xml @@ -10,7 +10,7 @@ android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:name="com.example.rideshare.MainActivity"> diff --git a/app/composeApp/src/androidMain/assets/findmyride.db b/app/composeApp/src/androidMain/assets/findmyride.db new file mode 100644 index 0000000..7019dce Binary files /dev/null and b/app/composeApp/src/androidMain/assets/findmyride.db differ diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidAuthRepository.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidAuthRepository.kt new file mode 100644 index 0000000..8e74451 --- /dev/null +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidAuthRepository.kt @@ -0,0 +1,116 @@ +package com.example.demo + +import app.composeapp.generated.resources.Res +import com.example.demo.feature.auth.data.AuthRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Android implementation of AuthRepository that talks to the findmyride.db SQLite database. + */ +class AndroidAuthRepository( + private val dbProvider: FindMyRideDbProvider +) : AuthRepository { + + + override suspend fun login(email: String, password: String): Result = + withContext(Dispatchers.IO) { + val db = dbProvider.getReadableDatabase() + + val cursor = db.rawQuery( + """ + SELECT user_id, email, username, role + FROM "USER" + WHERE email = ? AND password_hash = ? + """.trimIndent(), + arrayOf(email, password) + ) + + cursor.use { + return@withContext if (it.moveToFirst()) { + val idxId = it.getColumnIndexOrThrow("user_id") + val idxEmail = it.getColumnIndexOrThrow("email") + val idxUsername = it.getColumnIndexOrThrow("username") + val idxRole = it.getColumnIndexOrThrow("role") + + // Remember who is logged in + CurrentUserStore.userId = it.getLong(idxId) + CurrentUserStore.email = it.getString(idxEmail) + CurrentUserStore.username = it.getString(idxUsername) + CurrentUserStore.role = it.getString(idxRole) + + Result.success(Unit) + } else { + // Clear any previous user + CurrentUserStore.userId = null + CurrentUserStore.email = null + CurrentUserStore.username = null + CurrentUserStore.role = null + + Result.failure(IllegalArgumentException("Invalid email or password")) + } + } + } + + // Basic implementation so Sign Up / Forgot Password don't crash, + // we can improve these later. + override suspend fun signUp( + fullName: String, + email: String, + password: String + ): Result = + withContext(Dispatchers.IO) { + val db = dbProvider.getWritableDatabase() + + // Check if email already exists + db.rawQuery( + """SELECT 1 FROM "USER" WHERE email = ? LIMIT 1;""", + arrayOf(email) + ).use { c -> + if (c.moveToFirst()) { + return@withContext Result.failure( + IllegalArgumentException("Email already registered") + ) + } + } + + val username = if (fullName.isNotBlank()) { + fullName.trim().lowercase().replace(" ", "_") + } else { + email.substringBefore('@') + } + + val stmt = db.compileStatement( + """ + INSERT INTO "USER"(email, username, password_hash, role) + VALUES(?, ?, ?, 'rider'); + """.trimIndent() + ) + stmt.bindString(1, email) + stmt.bindString(2, username) + stmt.bindString(3, password) + + val rowId = stmt.executeInsert() + if (rowId == -1L) { + Result.failure(IllegalStateException("Failed to create account")) + } else { + Result.success(Unit) + } + } + + override suspend fun sendPasswordReset(email: String): Result = + withContext(Dispatchers.IO) { + val db = dbProvider.getReadableDatabase() + db.rawQuery( + """SELECT 1 FROM "USER" WHERE email = ? LIMIT 1;""", + arrayOf(email) + ).use { c -> + return@withContext if (c.moveToFirst()) { + // Pretend an email was sent + Result.success(Unit) + } else { + Result.failure(IllegalArgumentException("No account found for this email")) + } + } + } +} diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidMessagesRepository.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidMessagesRepository.kt new file mode 100644 index 0000000..600f517 --- /dev/null +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidMessagesRepository.kt @@ -0,0 +1,239 @@ +package com.example.demo.feature.messages.data + +import com.example.demo.CurrentUserStore +import com.example.demo.FindMyRideDbProvider +import com.example.demo.feature.messages.chat.ChatMessageUi +import com.example.demo.feature.messages.list.MessageThreadUi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AndroidMessagesRepository( + private val dbProvider: FindMyRideDbProvider +) : MessagesRepository { + + /** + * Use logged-in user if available, otherwise fall back to parameter. + */ + private fun resolveCurrentUserId(paramUserId: Int): Int { +// val stored = CurrentUserStore.userId +// return stored?.toInt() ?: paramUserId + return 1; + } + +// override suspend fun getThreadsForUser(userId: Int): List = +// withContext(Dispatchers.IO) { +// listOf( +// MessageThreadUi( +// id = 1, +// senderName = "Debug User", +// initials = "DU", +// lastMessage = "If you see this, repo wiring works!", +// timeAgo = "now", +// unreadCount = 0 +// ) +// ) +// } + + private fun seedFakeMessagesIfEmpty(db: android.database.sqlite.SQLiteDatabase) { + // Check if we already seeded + db.rawQuery("SELECT COUNT(*) FROM MESSAGE_THREAD;", null).use { c -> + if (c.moveToFirst()) { + val count = c.getInt(0) + if (count > 0) return // already has data, no need to seed + } + } + + // ---- THREADS ---- + // All involving user_id = 1 so they'll show when you log in as that user + db.execSQL(""" + INSERT INTO MESSAGE_THREAD (thread_id, user1_id, user2_id) + VALUES + (1, 1, 2), + (2, 1, 3), + (3, 1, 4); + """.trimIndent()) + + // ---- MESSAGES ---- + // Thread 1: Abdul <-> Quincy + db.execSQL(""" + INSERT INTO MESSAGE (thread_id, sender_id, body) VALUES + (1, 1, 'Hey Quincy, are we still on for 5:30 PM?'), + (1, 2, 'Yes! I''ll be there in 10 minutes.'), + (1, 1, 'Perfect, see you soon.'); + """.trimIndent()) + + // Thread 2: Abdul <-> Ame + db.execSQL(""" + INSERT INTO MESSAGE (thread_id, sender_id, body) VALUES + (2, 3, 'Hey Abdul, do you still need a ride tomorrow?'), + (2, 1, 'Yeah! Morning around 9 would be amazing.'), + (2, 3, 'Got you, I''ll swing by then.'); + """.trimIndent()) + + // Thread 3: Abdul <-> Kennan + db.execSQL(""" + INSERT INTO MESSAGE (thread_id, sender_id, body) VALUES + (3, 1, 'Thanks again for the last ride!'), + (3, 4, 'No problem, happy to help.'), + (3, 1, 'I left you a 5-star rating too :)'); + """.trimIndent()) + } + + + override suspend fun getThreadsForUser(userId: Int): List = + withContext(Dispatchers.IO) { + val db = dbProvider.getWritableDatabase() + val currentUserId = 1 // you’re logging in as user_id = 1 + + seedFakeMessagesIfEmpty(db) + + val sql = """ + SELECT + t.thread_id, + CASE + WHEN t.user1_id = ? THEN u2.username + ELSE u1.username + END AS contact_name, + COALESCE(last_msg.body, 'No messages yet') AS last_message, + last_msg.sent_at AS last_sent_at + FROM MESSAGE_THREAD t + JOIN "USER" u1 ON t.user1_id = u1.user_id + JOIN "USER" u2 ON t.user2_id = u2.user_id + LEFT JOIN MESSAGE last_msg ON last_msg.message_id = ( + SELECT m.message_id + FROM MESSAGE m + WHERE m.thread_id = t.thread_id + ORDER BY m.sent_at DESC, m.message_id DESC + LIMIT 1 + ) + WHERE t.user1_id = ? OR t.user2_id = ? + ORDER BY + (last_sent_at IS NULL), + last_sent_at DESC, + t.thread_id DESC; + """.trimIndent() + + val args = arrayOf( + currentUserId.toString(), + currentUserId.toString(), + currentUserId.toString() + ) + + val cursor = db.rawQuery(sql, args) + val result = mutableListOf() + + cursor.use { c -> + val idxThreadId = c.getColumnIndexOrThrow("thread_id") + val idxName = c.getColumnIndexOrThrow("contact_name") + val idxLastMsg = c.getColumnIndexOrThrow("last_message") + val idxLastSentAt = c.getColumnIndexOrThrow("last_sent_at") + + while (c.moveToNext()) { + val name = c.getString(idxName) + val lastMsg = c.getString(idxLastMsg) + val sentAt = c.getString(idxLastSentAt) ?: "" + + result += MessageThreadUi( + id = c.getInt(idxThreadId), + senderName = name, + initials = initialsFromName(name), + lastMessage = lastMsg, + timeAgo = sentAt, + unreadCount = 0 + ) + } + } + + result + } + + override suspend fun getMessagesForThread(threadId: Int): List = + withContext(Dispatchers.IO) { + val db = dbProvider.getReadableDatabase() + val currentUserId = resolveCurrentUserId(1) + + val cursor = db.rawQuery( + """ + SELECT message_id, sender_id, body, sent_at + FROM MESSAGE + WHERE thread_id = ? + ORDER BY sent_at ASC, message_id ASC; + """.trimIndent(), + arrayOf(threadId.toString()) + ) + + val result = mutableListOf() + cursor.use { c -> + val idxId = c.getColumnIndexOrThrow("message_id") + val idxSender= c.getColumnIndexOrThrow("sender_id") + val idxBody = c.getColumnIndexOrThrow("body") + val idxTime = c.getColumnIndexOrThrow("sent_at") + + while (c.moveToNext()) { + val senderId = c.getInt(idxSender) + result += ChatMessageUi( + id = c.getInt(idxId), + isMe = (senderId == currentUserId), + text = c.getString(idxBody), + time = c.getString(idxTime) + ) + } + } + result + } + + override suspend fun sendMessage(threadId: Int, text: String): ChatMessageUi = + withContext(Dispatchers.IO) { + val db = dbProvider.getWritableDatabase() + val currentUserId = resolveCurrentUserId(1) + + // Insert message + val insertStmt = db.compileStatement( + """ + INSERT INTO MESSAGE(thread_id, sender_id, body) + VALUES (?, ?, ?); + """.trimIndent() + ) + insertStmt.bindLong(1, threadId.toLong()) + insertStmt.bindLong(2, currentUserId.toLong()) + insertStmt.bindString(3, text) + val newIdLong = insertStmt.executeInsert() + + // Get timestamp (sent_at) + val cursor = db.rawQuery( + """ + SELECT sent_at + FROM MESSAGE + WHERE message_id = ?; + """.trimIndent(), + arrayOf(newIdLong.toString()) + ) + + var sentAt = "Now" + cursor.use { c -> + if (c.moveToFirst()) { + sentAt = c.getString(c.getColumnIndexOrThrow("sent_at")) + } + } + + ChatMessageUi( + id = newIdLong.toInt(), + isMe = true, + text = text, + time = sentAt + ) + } + + private fun initialsFromName(name: String): String { + val parts = name.trim().split(" ") + .filter { it.isNotBlank() } + + val chars = when { + parts.isEmpty() -> listOf('U') + parts.size == 1 -> listOf(parts[0].first()) + else -> listOf(parts[0].first(), parts[1].first()) + } + + return chars.joinToString("").uppercase() + } +} diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidProfileRepository.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidProfileRepository.kt new file mode 100644 index 0000000..1c9ba60 --- /dev/null +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidProfileRepository.kt @@ -0,0 +1,197 @@ +package com.example.demo.feature.profile.data + +import com.example.demo.CurrentUserStore +import com.example.demo.FindMyRideDbProvider +import com.example.demo.feature.profile.ProfileUiState +import com.example.demo.feature.profile.VehicleUi + +/** + * Android implementation of ProfileRepository that reads/writes + * the USER and VEHICLE tables from findmyride.db + */ +class AndroidProfileRepository( + private val dbProvider: FindMyRideDbProvider +) : ProfileRepository { + + private fun requireCurrentUserId(): Long { + return CurrentUserStore.userId + ?: error("No logged-in user. Make sure login ran successfully before opening Profile.") + } + + override fun loadInitialProfile(): ProfileUiState { + val currentUserId = requireCurrentUserId() + val db = dbProvider.getReadableDatabase() + + // ----- USER ----- + var name = "Unknown" + var email = "" + var phone = "" + var rating = 0.0 + + db.rawQuery( + """ + SELECT username, email, phone_number, rating_avg + FROM "USER" + WHERE user_id = ? + LIMIT 1; + """.trimIndent(), + arrayOf(currentUserId.toString()) + ).use { c -> + if (c.moveToFirst()) { + name = c.getString(c.getColumnIndexOrThrow("username")) + email = c.getString(c.getColumnIndexOrThrow("email")) + phone = c.getString(c.getColumnIndexOrThrow("phone_number")) ?: "" + rating = c.getDouble(c.getColumnIndexOrThrow("rating_avg")) + } + } + + // ----- VEHICLES ----- + val vehicles = mutableListOf() + db.rawQuery( + """ + SELECT vehicle_id, make, model, color, plate, seats_total, year, fun_fact + FROM "VEHICLE" + WHERE owner_user_id = ? + ORDER BY vehicle_id; + """.trimIndent(), + arrayOf(currentUserId.toString()) + ).use { c -> + val idxId = c.getColumnIndexOrThrow("vehicle_id") + val idxMake = c.getColumnIndexOrThrow("make") + val idxModel = c.getColumnIndexOrThrow("model") + val idxColor = c.getColumnIndexOrThrow("color") + val idxPlate = c.getColumnIndexOrThrow("plate") + val idxSeats = c.getColumnIndexOrThrow("seats_total") + val idxYear = c.getColumnIndexOrThrow("year") + val idxFunFact = c.getColumnIndexOrThrow("fun_fact") + + while (c.moveToNext()) { + vehicles += VehicleUi( + id = c.getInt(idxId), + ownerUserId = currentUserId.toInt(), + make = c.getString(idxMake), + model = c.getString(idxModel), + color = c.getString(idxColor) ?: "", + plate = c.getString(idxPlate), + seatsTotal = c.getInt(idxSeats), + year = c.getInt(idxYear), + funFact = c.getString(idxFunFact) ?: "" + ) + } + } + + // Build ProfileUiState using DB values + return ProfileUiState( + name = name, + email = email, + rating = rating, + vehicles = vehicles, + account = ProfileUiState().account.copy( + fullName = name, + email = email, + phone = phone, + // we don't show real password here + password = "*******" + ) + ) + } + + override fun saveProfile(state: ProfileUiState) { + val currentUserId = requireCurrentUserId() + val db = dbProvider.getWritableDatabase() + db.beginTransaction() + try { + // ----- UPDATE USER ----- + db.compileStatement( + """ + UPDATE "USER" + SET username = ?, email = ?, phone_number = ? + WHERE user_id = ?; + """.trimIndent() + ).apply { + bindString(1, state.account.fullName) + bindString(2, state.account.email) + bindString(3, state.account.phone) + bindLong(4, currentUserId) + executeUpdateDelete() + } + + // ----- SYNC VEHICLES ----- + // Get existing vehicle ids in DB + val existingIds = mutableSetOf() + db.rawQuery( + """ + SELECT vehicle_id + FROM "VEHICLE" + WHERE owner_user_id = ?; + """.trimIndent(), + arrayOf(currentUserId.toString()) + ).use { c -> + val idx = c.getColumnIndexOrThrow("vehicle_id") + while (c.moveToNext()) existingIds += c.getInt(idx) + } + + val desiredIds = state.vehicles.map { it.id }.toSet() + + // Upsert each vehicle from UI state + state.vehicles.forEach { v -> + if (existingIds.contains(v.id)) { + // UPDATE + db.compileStatement( + """ + UPDATE "VEHICLE" + SET make = ?, model = ?, color = ?, plate = ?, + seats_total = ?, year = ?, fun_fact = ? + WHERE vehicle_id = ?; + """.trimIndent() + ).apply { + bindString(1, v.make) + bindString(2, v.model) + bindString(3, v.color) + bindString(4, v.plate) + bindLong(5, v.seatsTotal.toLong()) + bindLong(6, v.year.toLong()) + bindString(7, v.funFact) + bindLong(8, v.id.toLong()) + executeUpdateDelete() + } + } else { + // INSERT with explicit vehicle_id (matches the id created in ViewModel) + db.compileStatement( + """ + INSERT INTO "VEHICLE"( + vehicle_id, owner_user_id, make, model, color, + plate, seats_total, year, fun_fact + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + """.trimIndent() + ).apply { + bindLong(1, v.id.toLong()) + bindLong(2, currentUserId) + bindString(3, v.make) + bindString(4, v.model) + bindString(5, v.color) + bindString(6, v.plate) + bindLong(7, v.seatsTotal.toLong()) + bindLong(8, v.year.toLong()) + bindString(9, v.funFact) + executeInsert() + } + } + } + + // Delete vehicles that were removed in the UI + (existingIds - desiredIds).forEach { idToDelete -> + db.compileStatement( + """DELETE FROM "VEHICLE" WHERE vehicle_id = ?;""".trimIndent() + ).apply { + bindLong(1, idToDelete.toLong()) + executeUpdateDelete() + } + } + + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } +} diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidRideRepository.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidRideRepository.kt new file mode 100644 index 0000000..335ba12 --- /dev/null +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/AndroidRideRepository.kt @@ -0,0 +1,117 @@ +package com.example.demo + +import android.content.ContentValues +import com.example.demo.feature.db.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AndroidRideRepository( + private val dbProvider: FindMyRideDbProvider +) : RideRepository { + + override suspend fun getOpenRideOffers(): List = + withContext(Dispatchers.IO) { + val db = dbProvider.getReadableDatabase() + + // Example: join RIDE_OFFER with LOCATION to get names + val sql = """ + SELECT + o.offer_id, + o.driver_id, + o.vehicle_id, + lo_from.name AS from_name, + lo_to.name AS to_name, + o.depart_at, + o.seats_available, + o.price_base + FROM RIDE_OFFER o + JOIN LOCATION lo_from ON o.original_location_id = lo_from.location_id + JOIN LOCATION lo_to ON o.dest_location_id = lo_to.location_id + WHERE o.status = 'open' + ORDER BY o.depart_at ASC; + """.trimIndent() + + val cursor = db.rawQuery(sql, null) + + val list = mutableListOf() + cursor.use { + val idxOfferId = it.getColumnIndexOrThrow("offer_id") + val idxDriverId = it.getColumnIndexOrThrow("driver_id") + val idxVehicleId = it.getColumnIndexOrThrow("vehicle_id") + val idxFromName = it.getColumnIndexOrThrow("from_name") + val idxToName = it.getColumnIndexOrThrow("to_name") + val idxDepartAt = it.getColumnIndexOrThrow("depart_at") + val idxSeatsAvail = it.getColumnIndexOrThrow("seats_available") + val idxPriceBase = it.getColumnIndexOrThrow("price_base") + + while (it.moveToNext()) { + list.add( + RideOffer( + offerId = it.getLong(idxOfferId), + driverId = it.getLong(idxDriverId), + vehicleId = it.getLong(idxVehicleId), + fromName = it.getString(idxFromName), + toName = it.getString(idxToName), + departAt = it.getString(idxDepartAt), + seatsAvailable = it.getInt(idxSeatsAvail), + priceBase = it.getDouble(idxPriceBase) + ) + ) + } + } + list + } + + override suspend fun createRideRequest( + riderId: Long, + pickupLocationId: Long, + dropoffLocationId: Long, + earliestPickup: String, + latestPickup: String?, + seatsNeeded: Int + ) { + withContext(Dispatchers.IO) { + val db = dbProvider.getWritableDatabase() + val values = ContentValues().apply { + put("rider_id", riderId) + put("pickup_location_id", pickupLocationId) + put("dropoff_location_id", dropoffLocationId) + put("earliest_pickup", earliestPickup) + put("latest_pickup", latestPickup) + put("seats_needed", seatsNeeded) + put("status", "open") + } + db.insert("RIDE_REQUEST", null, values) + } + } + + override suspend fun createRideOffer( + driverId: Long, + vehicleId: Long, + originalLocationId: Long, + destLocationId: Long, + departAt: String, + seatsAvailable: Int, + priceBase: Double, + pricePerMile: Double + ) { + withContext(Dispatchers.IO) { + val db = dbProvider.getWritableDatabase() + + val values = ContentValues().apply { + put("driver_id", driverId) + put("vehicle_id", vehicleId) + put("original_location_id", originalLocationId) + put("dest_location_id", destLocationId) + put("depart_at", departAt) + put("seats_available", seatsAvailable) + put("price_base", priceBase) + put("price_per_mile", pricePerMile) + put("status", "open") + } + + db.insert("RIDE_OFFER", null, values) + } + } + +} diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/CurrentUserStore.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/CurrentUserStore.kt new file mode 100644 index 0000000..cf175de --- /dev/null +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/CurrentUserStore.kt @@ -0,0 +1,14 @@ +package com.example.demo + +/** + * Simple in-memory store for the currently logged-in user. + * Android only; commonMain doesn't see this directly. + */ +object CurrentUserStore { + var userId: Long? = null + var email: String? = null + var username: String? = null + var role: String? = null +} + +// This is just a global holder on the Android side. \ No newline at end of file diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/FindMyRideDbProvider.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/FindMyRideDbProvider.kt new file mode 100644 index 0000000..6adb08a --- /dev/null +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/FindMyRideDbProvider.kt @@ -0,0 +1,48 @@ +package com.example.demo + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import java.io.FileOutputStream + +class FindMyRideDbProvider(private val context: Context) { + private val dbName = "findmyride.db" + + private fun ensureDbCopied() { + val dbFile = context.getDatabasePath(dbName) + + // Always overwrite old database to ensure latest version is used + dbFile.parentFile?.mkdirs() + + // Delete any existing DB + if (dbFile.exists()) { + dbFile.delete() + } + + // Copy fresh DB from assets + context.assets.open(dbName).use { input -> + dbFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + + fun getReadableDatabase(): SQLiteDatabase { + ensureDbCopied() + val dbFile = context.getDatabasePath(dbName) + return SQLiteDatabase.openDatabase( + dbFile.path, + null, + SQLiteDatabase.OPEN_READONLY + ) + } + + fun getWritableDatabase(): SQLiteDatabase { + ensureDbCopied() + val dbFile = context.getDatabasePath(dbName) + return SQLiteDatabase.openDatabase( + dbFile.path, + null, + SQLiteDatabase.OPEN_READWRITE + ) + } +} \ No newline at end of file diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/MainActivity.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/MainActivity.kt index a3397f1..9ff986d 100644 --- a/app/composeApp/src/androidMain/kotlin/com/example/demo/MainActivity.kt +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/MainActivity.kt @@ -1,26 +1,43 @@ -package com.example.app +package com.example.rideshare import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import com.example.demo.App +import com.example.demo.AndroidRideRepository +import com.example.demo.AndroidAuthRepository +import com.example.demo.FindMyRideDbProvider +import com.example.demo.CurrentUserStore +import com.example.demo.feature.profile.data.AndroidProfileRepository +import com.example.demo.feature.messages.data.AndroidMessagesRepository class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { - App() + MaterialTheme { + Surface { + val context = LocalContext.current + val dbProvider = remember { FindMyRideDbProvider(context) } + + val rideRepo = remember { AndroidRideRepository(dbProvider) } + val profileRepo = remember { AndroidProfileRepository(dbProvider) } + val authRepo = remember { AndroidAuthRepository(dbProvider) } + val messagesRepo = remember { AndroidMessagesRepository(dbProvider) } + + App( + rideRepository = rideRepo, + profileRepository = profileRepo, + authRepository = authRepo, + messagesRepository = messagesRepo + ) + } + } } } } - -@Preview -@Composable -fun AppAndroidPreview() { - App() -} \ No newline at end of file diff --git a/app/composeApp/src/androidMain/kotlin/com/example/demo/RideShareDbHelper.kt b/app/composeApp/src/androidMain/kotlin/com/example/demo/RideShareDbHelper.kt new file mode 100644 index 0000000..777c33d --- /dev/null +++ b/app/composeApp/src/androidMain/kotlin/com/example/demo/RideShareDbHelper.kt @@ -0,0 +1,29 @@ +package com.example.demo + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class RideShareDbHelper(context: Context) : + SQLiteOpenHelper(context, "rideshare.db", null, 1) { + + override fun onCreate(db: SQLiteDatabase) { + // Create a super simple table for now + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS rides( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pickup TEXT NOT NULL, + dropoff TEXT NOT NULL, + ride_time TEXT NOT NULL + ); + """.trimIndent() + ) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // Easiest upgrade strategy for school project + db.execSQL("DROP TABLE IF EXISTS rides;") + onCreate(db) + } +} diff --git a/app/composeApp/src/commonMain/composeResources/drawable/ic_back_arrow.xml b/app/composeApp/src/commonMain/composeResources/drawable/ic_back_arrow.xml new file mode 100644 index 0000000..125cf9c --- /dev/null +++ b/app/composeApp/src/commonMain/composeResources/drawable/ic_back_arrow.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/composeApp/src/commonMain/composeResources/drawable/ic_calendar.xml b/app/composeApp/src/commonMain/composeResources/drawable/ic_calendar.xml new file mode 100644 index 0000000..0cd1ed0 --- /dev/null +++ b/app/composeApp/src/commonMain/composeResources/drawable/ic_calendar.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/composeApp/src/commonMain/composeResources/drawable/ic_clock.xml b/app/composeApp/src/commonMain/composeResources/drawable/ic_clock.xml new file mode 100644 index 0000000..df68293 --- /dev/null +++ b/app/composeApp/src/commonMain/composeResources/drawable/ic_clock.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/composeApp/src/commonMain/composeResources/drawable/ic_dollar_sign.xml b/app/composeApp/src/commonMain/composeResources/drawable/ic_dollar_sign.xml new file mode 100644 index 0000000..21e87fc --- /dev/null +++ b/app/composeApp/src/commonMain/composeResources/drawable/ic_dollar_sign.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/composeApp/src/commonMain/composeResources/drawable/ic_location_ping.xml b/app/composeApp/src/commonMain/composeResources/drawable/ic_location_ping.xml new file mode 100644 index 0000000..fae4e07 --- /dev/null +++ b/app/composeApp/src/commonMain/composeResources/drawable/ic_location_ping.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/composeApp/src/commonMain/composeResources/drawable/ic_two_people.xml b/app/composeApp/src/commonMain/composeResources/drawable/ic_two_people.xml new file mode 100644 index 0000000..05f423f --- /dev/null +++ b/app/composeApp/src/commonMain/composeResources/drawable/ic_two_people.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/composeApp/src/commonMain/composeResources/drawable/ic_vehicle.xml b/app/composeApp/src/commonMain/composeResources/drawable/ic_vehicle.xml new file mode 100644 index 0000000..b50b2b1 --- /dev/null +++ b/app/composeApp/src/commonMain/composeResources/drawable/ic_vehicle.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/App.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/App.kt index 6ed3ec2..129cb15 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/App.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/App.kt @@ -2,11 +2,14 @@ package com.example.demo import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* +import com.example.demo.feature.auth.data.AuthRepository import com.example.demo.feature.auth.login.LoginRoute import com.example.demo.feature.auth.signup.SignUpRoute import com.example.demo.feature.auth.forgot.ForgotPasswordRoute +import com.example.demo.feature.db.RideRepository import com.example.demo.feature.main.MainRoute -import com.example.demo.feature.profile.ProfileRoute +import com.example.demo.feature.messages.data.MessagesRepository +import com.example.demo.feature.profile.data.ProfileRepository // Top-level screens in your app enum class RootScreen { @@ -17,7 +20,12 @@ enum class RootScreen { } @Composable -fun App() { +fun App( + rideRepository: RideRepository, + profileRepository: ProfileRepository, + authRepository: AuthRepository, + messagesRepository: MessagesRepository +) { var currentScreen by remember { mutableStateOf(RootScreen.Login) } MaterialTheme { @@ -26,7 +34,8 @@ fun App() { RootScreen.Login -> LoginRoute( onNavigateToSignUp = { currentScreen = RootScreen.SignUp }, onNavigateToForgotPassword = { currentScreen = RootScreen.ForgotPassword }, - onLoginSuccess = { currentScreen = RootScreen.Main } + onLoginSuccess = { currentScreen = RootScreen.Main }, + authRepository = authRepository, ) RootScreen.SignUp -> SignUpRoute( @@ -37,7 +46,12 @@ fun App() { onNavigateBack = { currentScreen = RootScreen.Login } ) - RootScreen.Main -> MainRoute() + RootScreen.Main -> MainRoute( + rideRepository = rideRepository, + profileRepository = profileRepository, + messagesRepository = messagesRepository, + + ) } } } diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginRoute.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginRoute.kt index 52cd63f..a75e553 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginRoute.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginRoute.kt @@ -1,15 +1,20 @@ package com.example.demo.feature.auth.login +import FakeAuthRepository import androidx.compose.runtime.* import androidx.compose.runtime.collectAsState +import com.example.demo.feature.auth.data.AuthRepository @Composable fun LoginRoute( onNavigateToSignUp: () -> Unit, onNavigateToForgotPassword: () -> Unit, onLoginSuccess: () -> Unit, - viewModel: LoginViewModel = remember { LoginViewModel() } + authRepository: AuthRepository ) { + val viewModel = remember(authRepository) { + LoginViewModel(authRepository) + } val state by viewModel.uiState.collectAsState() // When login succeeds, trigger navigation once diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginViewModel.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginViewModel.kt index 3f0b082..7ec7094 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginViewModel.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/auth/login/LoginViewModel.kt @@ -1,6 +1,5 @@ package com.example.demo.feature.auth.login -import FakeAuthRepository import com.example.demo.feature.auth.data.AuthRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -10,7 +9,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class LoginViewModel( - private val repository: AuthRepository = FakeAuthRepository() + private val repository: AuthRepository ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -19,11 +18,21 @@ class LoginViewModel( fun onEvent(event: LoginEvent) { when (event) { - is LoginEvent.EmailChanged -> - _uiState.value = _uiState.value.copy(email = event.value, errorMessage = null) + is LoginEvent.EmailChanged -> { + _uiState.value = _uiState.value.copy( + email = event.value, + errorMessage = null, + loginSuccess = false + ) + } - is LoginEvent.PasswordChanged -> - _uiState.value = _uiState.value.copy(password = event.value, errorMessage = null) + is LoginEvent.PasswordChanged -> { + _uiState.value = _uiState.value.copy( + password = event.value, + errorMessage = null, + loginSuccess = false + ) + } LoginEvent.Submit -> submit() } @@ -31,36 +40,37 @@ class LoginViewModel( private fun submit() { val state = _uiState.value - val email = state.email.trim() - val password = state.password - // Required fields - if (email.isBlank() || password.isBlank()) { - return setError("Email and password are required.") + if (!isValidEmail(state.email)) { + setError("Please enter a valid email") + return } - - // Basic email validation - if (!isValidEmail(email)) { - return setError("Please enter a valid email address.") + if (state.password.isBlank()) { + setError("Password is required") + return } + // start loading _uiState.value = state.copy( isLoading = true, - errorMessage = null + errorMessage = null, + loginSuccess = false ) scope.launch { - val result = repository.login(email, password) + val result = repository.login(state.email.trim(), state.password) - if (result.isSuccess) { - _uiState.value = _uiState.value.copy( + _uiState.value = if (result.isSuccess) { + _uiState.value.copy( isLoading = false, loginSuccess = true, + errorMessage = null ) } else { - _uiState.value = _uiState.value.copy( + _uiState.value.copy( isLoading = false, - errorMessage = result.exceptionOrNull()?.message ?: "Login failed." + loginSuccess = false, + errorMessage = result.exceptionOrNull()?.message ?: "Login failed" ) } } @@ -69,14 +79,15 @@ class LoginViewModel( private fun setError(message: String) { _uiState.value = _uiState.value.copy( errorMessage = message, - isLoading = false + isLoading = false, + loginSuccess = false ) } + // Same simple cross-platform email validation you used before private fun isValidEmail(email: String): Boolean { val at = email.indexOf('@') val dot = email.lastIndexOf('.') return at > 0 && dot > at + 1 && dot < email.length - 1 } } - diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/db/RideRepository.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/db/RideRepository.kt new file mode 100644 index 0000000..d61f199 --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/db/RideRepository.kt @@ -0,0 +1,35 @@ +package com.example.demo.feature.db + +data class RideOffer( + val offerId: Long, + val driverId: Long, + val vehicleId: Long, + val fromName: String, + val toName: String, + val departAt: String, + val seatsAvailable: Int, + val priceBase: Double +) + +interface RideRepository { + suspend fun getOpenRideOffers(): List + suspend fun createRideRequest( + riderId: Long, + pickupLocationId: Long, + dropoffLocationId: Long, + earliestPickup: String, + latestPickup: String?, + seatsNeeded: Int + ) + + suspend fun createRideOffer( + driverId: Long, + vehicleId: Long, + originalLocationId: Long, + destLocationId: Long, + departAt: String, + seatsAvailable: Int, + priceBase: Double, + pricePerMile: Double + ) +} diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/HomeScreen.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/HomeScreen.kt new file mode 100644 index 0000000..d7b996d --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/HomeScreen.kt @@ -0,0 +1,238 @@ +package com.example.demo.feature.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import com.example.demo.ui.theme.DrexelBlue +import com.example.demo.ui.theme.DrexelGold +import com.example.demo.ui.theme.FieldBackground +import com.example.demo.ui.theme.HintGrey + + +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + onFindRideClick: () -> Unit = {}, + onOfferRideClick: () -> Unit = {} +) { + Column( + modifier = modifier + .fillMaxSize() + .background(FieldBackground) + ) { + // ---------- HEADER ---------- + Box( + modifier = Modifier + .fillMaxWidth() + .background(DrexelBlue) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Welcome back!", + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Where would you like to go?", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.8f) + ) + } + + IconButton(onClick = { /* settings later */ }) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = "Settings", + tint = Color.White + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // ---------- MAIN CARDS ---------- + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f, fill = true), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Find a Ride card + HomeActionCard( + title = "Find a Ride", + subtitle = "Search for available rides to your destination", + iconBgColor = Color(0xFFE3F2FD), + iconTint = DrexelBlue, + icon = Icons.Filled.People, + buttonText = "Get started →", + onClick = onFindRideClick + ) + + // Offer a Ride card + HomeActionCard( + title = "Offer a Ride", + subtitle = "Share your trip and earn money", + iconBgColor = Color(0xFFFFF4CC), + iconTint = DrexelGold, + icon = Icons.Filled.DirectionsCar, + buttonText = "Get started →", + onClick = onOfferRideClick + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Stats row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + label = "Rides", + value = "12", + modifier = Modifier.weight(1f) + ) + StatCard( + label = "Rating", + value = "4.8★", + modifier = Modifier.weight(1f) + ) + StatCard( + label = "Saved", + value = "$142", + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +private fun HomeActionCard( + title: String, + subtitle: String, + iconBgColor: Color, + iconTint: Color, + icon: ImageVector, + buttonText: String, + onClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = DrexelBlue + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = HintGrey + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(iconBgColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + } + + TextButton(onClick = onClick) { + Text( + text = buttonText, + color = DrexelBlue, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } +} + +@Composable +private fun StatCard( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = DrexelBlue + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = HintGrey + ) + } + } +} diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/MainRoute.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/MainRoute.kt index c4f681b..1b85323 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/MainRoute.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/MainRoute.kt @@ -9,49 +9,92 @@ import androidx.compose.ui.unit.dp import com.example.demo.feature.profile.ProfileRoute import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import com.example.demo.feature.db.RideRepository import com.example.demo.feature.messages.MessagesRoute +import com.example.demo.feature.messages.data.MessagesRepository +import com.example.demo.feature.profile.data.ProfileRepository +import com.example.demo.feature.rides.AvailableOfferScreen +import com.example.demo.feature.rides.AvailableRidesScreen +import com.example.demo.feature.rides.MyRidesScreen import com.example.demo.ui.theme.DrexelBlue import com.example.demo.ui.theme.DrexelGold enum class MainTab { Home, Rides, Messages, Profile } +private enum class HomePage { Dashboard, AvailableRides, OfferRide } @Composable -fun MainRoute() { - var currentTab by remember { mutableStateOf(MainTab.Home) } // start on profile for now +fun MainRoute( + rideRepository: RideRepository, + profileRepository: ProfileRepository, + messagesRepository: MessagesRepository, +) { + var currentTab by remember { mutableStateOf(MainTab.Home) } + var homePage by remember { mutableStateOf(HomePage.Dashboard) } Scaffold( bottomBar = { MainBottomNav( currentTab = currentTab, - onTabSelected = { currentTab = it } + onTabSelected = { tab -> + currentTab = tab + if (tab == MainTab.Home) { + homePage = HomePage.Dashboard // reset when returning to Home tab + } + } ) }, containerColor = Color(0xFFF5F5F7) ) { padding -> when (currentTab) { MainTab.Home -> { - // TODO: replace with real HomeRoute - Text( - text = "Home screen placeholder", - modifier = Modifier.padding(padding) - ) + when (homePage) { + HomePage.Dashboard -> { + HomeScreen( + modifier = Modifier.padding(padding), + onFindRideClick = { homePage = HomePage.AvailableRides }, + onOfferRideClick = { homePage = HomePage.OfferRide }, + ) + } + HomePage.AvailableRides -> { + AvailableRidesScreen( + modifier = Modifier.padding(padding), + onBack = { homePage = HomePage.Dashboard }, + rideRepository = rideRepository + ) + } + HomePage.OfferRide -> { + AvailableOfferScreen( + modifier = Modifier.padding(padding), + onBack = { homePage = HomePage.Dashboard }, + onPublish = { + homePage = HomePage.Dashboard + } + ) + } + } } MainTab.Rides -> { - Text( - text = "My Rides placeholder", + MyRidesScreen( modifier = Modifier.padding(padding) ) } MainTab.Messages -> { - MessagesRoute(modifier = Modifier.padding(padding)) + MessagesRoute( + modifier = Modifier.padding(padding), + repository = messagesRepository, + ) } MainTab.Profile -> { - ProfileRoute(modifier = Modifier.padding(padding)) + ProfileRoute( + modifier = Modifier.padding(padding), + repository = profileRepository + ) } } } } + @Composable private fun MainBottomNav( currentTab: MainTab, diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/MyRideScreen.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/MyRideScreen.kt new file mode 100644 index 0000000..8e7733f --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/main/MyRideScreen.kt @@ -0,0 +1,352 @@ +package com.example.demo.feature.rides + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.LocationOn +import com.example.demo.ui.theme.DrexelBlue +import com.example.demo.ui.theme.FieldBackground +import com.example.demo.ui.theme.HintGrey + +private enum class MyRidesTab { Upcoming, Completed } + +data class RideHistoryItem( + val id: Int, + val status: String, + val role: String, + val driverName: String, + val pickup: String, + val dropoff: String, + val date: String, + val time: String, + val price: String +) + +@Composable +fun MyRidesScreen( + modifier: Modifier = Modifier +) { + // fake data for now + val upcomingRides = listOf( + RideHistoryItem( + id = 1, + status = "Confirmed", + role = "Passenger", + driverName = "Abdul B.", + pickup = "30th Street Station", + dropoff = "Cira Green", + date = "Nov 11, 2025", + time = "5:30 PM", + price = "$11.71" + ) + ) + + val completedRides = listOf( + RideHistoryItem( + id = 2, + status = "Passenger", // only pill we show + role = "", // leave empty so no second pill + driverName = "Sarah M.", + pickup = "University Crossings", + dropoff = "Downtown Philadelphia", + date = "Nov 5, 2025", + time = "8:00 AM", + price = "$15.5" + ), + RideHistoryItem( + id = 3, + status = "Driver", + role = "", + driverName = "You", // or whoever + pickup = "West Philadelphia", + dropoff = "King of Prussia", + date = "Nov 1, 2025", + time = "6:30 PM", + price = "$28" + ), + RideHistoryItem( + id = 4, + status = "Passenger", + role = "", + driverName = "Michael T.", + pickup = "Temple University", + dropoff = "Chestnut Hill", + date = "Oct 28, 2025", + time = "3:15 PM", + price = "$12.75" + ) + ) + + var currentTab by remember { mutableStateOf(MyRidesTab.Upcoming) } + + Column( + modifier = modifier + .fillMaxSize() + .background(FieldBackground) + ) { + // ------- HEADER -------- + Box( + modifier = Modifier + .fillMaxWidth() + .background(DrexelBlue) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp) + ) { + Text( + text = "My Rides", + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Your ride history & upcoming trips", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.8f) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // segmented control + Surface( + shape = RoundedCornerShape(24.dp), + color = Color.White, + tonalElevation = 4.dp, + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + SegmentedTab( + text = "Upcoming", + selected = currentTab == MyRidesTab.Upcoming, + modifier = Modifier.weight(1f) + ) { currentTab = MyRidesTab.Upcoming } + + SegmentedTab( + text = "Completed", + selected = currentTab == MyRidesTab.Completed, + modifier = Modifier.weight(1f) + ) { currentTab = MyRidesTab.Completed } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // ------- LIST CONTENT ------- + val ridesToShow = + if (currentTab == MyRidesTab.Upcoming) upcomingRides else completedRides + + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(ridesToShow, key = { it.id }) { ride -> + RideHistoryCard(ride) + } + + if (ridesToShow.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No rides in this section yet.", + color = HintGrey + ) + } + } + } + } + } +} + +@Composable +private fun SegmentedTab( + text: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val bg = if (selected) Color.White else Color.Transparent + val textColor = if (selected) DrexelBlue else HintGrey + + Box( + modifier = modifier + .fillMaxHeight() + ) { + TextButton( + onClick = onClick, + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(0.dp) + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = if (selected) Color(0xFFEFF3FF) else Color.Transparent + ) { + Box( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + fontSize = 14.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = textColor + ) + } + } + } + } +} + +@Composable +private fun RideHistoryCard(ride: RideHistoryItem) { + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + // status row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // always show the first pill + PillChip( + text = ride.status, + bg = Color(0xFFE3F8EA), + textColor = Color(0xFF2C7A43) + ) + + // only show second pill when role is non-blank + if (ride.role.isNotBlank()) { + PillChip( + text = ride.role, + bg = Color(0xFFEAF0FF), + textColor = DrexelBlue + ) + } + } + Text( + text = ride.price, + fontWeight = FontWeight.Bold, + color = DrexelBlue + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Driver: ${ride.driverName}", + style = MaterialTheme.typography.bodyMedium, + color = HintGrey + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // locations + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = null, + tint = HintGrey, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = ride.pickup, + style = MaterialTheme.typography.bodyMedium, + color = HintGrey + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = null, + tint = HintGrey, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = ride.dropoff, + style = MaterialTheme.typography.bodyMedium, + color = HintGrey + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // date + time + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.CalendarToday, + contentDescription = null, + tint = HintGrey, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${ride.date} ${ride.time}", + style = MaterialTheme.typography.bodyMedium, + color = HintGrey + ) + } + } + } +} + +@Composable +private fun PillChip( + text: String, + bg: Color, + textColor: Color +) { + Surface( + color = bg, + shape = RoundedCornerShape(50) + ) { + Text( + text = text, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } +} diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/MessagesRoute.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/MessagesRoute.kt index a014cf3..3d1d1e4 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/MessagesRoute.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/MessagesRoute.kt @@ -3,6 +3,7 @@ package com.example.demo.feature.messages import androidx.compose.runtime.* import androidx.compose.ui.Modifier import com.example.demo.feature.messages.chat.ChatDetailRoute +import com.example.demo.feature.messages.data.MessagesRepository import com.example.demo.feature.messages.list.MessageThreadUi import com.example.demo.feature.messages.list.MessagesListRoute @@ -10,20 +11,23 @@ private enum class MessagesPage { List, Conversation } @Composable fun MessagesRoute( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + repository: MessagesRepository ) { var currentPage by remember { mutableStateOf(MessagesPage.List) } var activeThread by remember { mutableStateOf(null) } when (currentPage) { - MessagesPage.List -> MessagesListRoute( - modifier = modifier, - onOpenConversation = { thread -> - activeThread = thread - currentPage = MessagesPage.Conversation - } - ) - + MessagesPage.List -> { + MessagesListRoute( + modifier = modifier, + repository = repository, + onOpenConversation = { thread -> + activeThread = thread + currentPage = MessagesPage.Conversation + } + ) + } MessagesPage.Conversation -> { val thread = activeThread if (thread == null) { @@ -33,6 +37,7 @@ fun MessagesRoute( threadId = thread.id, contactName = thread.senderName, initials = thread.initials, + repository = repository, onBack = { currentPage = MessagesPage.List }, modifier = modifier ) @@ -40,3 +45,4 @@ fun MessagesRoute( } } } + diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailRoute.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailRoute.kt index 54ad70a..aa6ed9f 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailRoute.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailRoute.kt @@ -2,18 +2,20 @@ package com.example.demo.feature.messages.chat import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import com.example.demo.feature.messages.data.MessagesRepository @Composable fun ChatDetailRoute( threadId: Int, contactName: String, initials: String, + repository: MessagesRepository, onBack: () -> Unit, - modifier: Modifier = Modifier, - viewModel: ChatDetailViewModel = remember { - ChatDetailViewModel(threadId, contactName, initials) - } + modifier: Modifier = Modifier ) { + val viewModel = remember(threadId, repository) { + ChatDetailViewModel(threadId, contactName, initials, repository) + } val state by viewModel.uiState.collectAsState() ChatDetailScreen( diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailViewModel.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailViewModel.kt index fb650ea..86eebad 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailViewModel.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/chat/ChatDetailViewModel.kt @@ -1,5 +1,6 @@ package com.example.demo.feature.messages.chat +import com.example.demo.feature.messages.data.MessagesRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -12,6 +13,7 @@ class ChatDetailViewModel( private val threadId: Int, contactName: String, initials: String, + private val repository: MessagesRepository ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -30,15 +32,22 @@ class ChatDetailViewModel( private fun loadMessages() { scope.launch { - // fake loading – later replace with repository - _uiState.update { - it.copy( - isLoading = false, - messages = listOf( - ChatMessageUi(1, false, "Hey!", "3h ago"), - ChatMessageUi(2, true, "Yo", "3h ago"), + try { + val msgs = repository.getMessagesForThread(threadId) + _uiState.update { + it.copy( + isLoading = false, + messages = msgs, + errorMessage = null ) - ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "Failed to load messages" + ) + } } } } @@ -56,13 +65,23 @@ class ChatDetailViewModel( val text = _uiState.value.newMessageText.trim() if (text.isBlank()) return - val newId = (_uiState.value.messages.maxOfOrNull { it.id } ?: 0) + 1 - - _uiState.update { - it.copy( - messages = it.messages + ChatMessageUi(newId, true, text, "Now"), - newMessageText = "" - ) + scope.launch { + try { + val msg = repository.sendMessage(threadId, text) + _uiState.update { + it.copy( + messages = it.messages + msg, + newMessageText = "", + errorMessage = null + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + errorMessage = e.message ?: "Failed to send message" + ) + } + } } } } diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesListRoute.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesListRoute.kt index 55b6164..c446f70 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesListRoute.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesListRoute.kt @@ -2,13 +2,23 @@ package com.example.demo.feature.messages.list import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import com.example.demo.feature.messages.data.MessagesRepository @Composable fun MessagesListRoute( modifier: Modifier = Modifier, onOpenConversation: (MessageThreadUi) -> Unit, - viewModel: MessagesViewModel = remember { MessagesViewModel() } + repository: MessagesRepository ) { + // For now, we use userId = 1; AndroidMessagesRepository internally + // will override this with CurrentUserStore.userId when available. + val viewModel = remember(repository) { + MessagesViewModel( + repository = repository, + userId = 1 + ) + } + val state by viewModel.uiState.collectAsState() MessagesScreen( diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesViewModel.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesViewModel.kt index 71f9ccc..b1bed78 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesViewModel.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/messages/list/MessagesViewModel.kt @@ -1,7 +1,6 @@ package com.example.demo.feature.messages.list import com.example.demo.feature.messages.data.MessagesRepository -import com.example.demo.feature.messages.data.FakeMessagesRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -10,12 +9,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class MessagesViewModel( - private val repository: MessagesRepository = FakeMessagesRepository(), - private val userId: Int = 1 // fake current user + private val repository: MessagesRepository, + private val userId: Int ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val _uiState = MutableStateFlow(MessagesUiState(isLoading = true)) + private val _uiState = MutableStateFlow(MessagesUiState()) val uiState: StateFlow = _uiState init { diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileScreen.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileScreen.kt index c19250a..cbc6fc5 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileScreen.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileScreen.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.sp import com.example.demo.feature.profile.components.SettingsCard import com.example.demo.feature.profile.components.VehicleEditDialog import com.example.demo.feature.profile.components.VehiclesCard +import com.example.demo.feature.profile.data.InMemoryProfileRepository +import com.example.demo.feature.profile.data.ProfileRepository import com.example.demo.feature.profile.pages.AccountSettingsScreen import com.example.demo.feature.profile.pages.PreferencesScreen import com.example.demo.feature.profile.pages.PrivacySafetyScreen @@ -35,9 +37,10 @@ enum class ProfilePage { @Composable fun ProfileRoute( - viewModel: ProfileViewModel = remember { ProfileViewModel() }, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + repository: ProfileRepository ) { + val viewModel = remember(repository) { ProfileViewModel(repository) } var currentPage by remember { mutableStateOf(ProfilePage.Overview) } val state by viewModel.uiState.collectAsState() @@ -46,35 +49,36 @@ fun ProfileRoute( state = state, onEvent = viewModel::onEvent, onAccountSettingsClick = { currentPage = ProfilePage.AccountSettings }, - onPreferencesClick = { currentPage = ProfilePage.Preferences }, - onPrivacySafetyClick = { currentPage = ProfilePage.PrivacySafety }, + onPreferencesClick = { currentPage = ProfilePage.Preferences }, + onPrivacySafetyClick = { currentPage = ProfilePage.PrivacySafety }, modifier = modifier ) ProfilePage.AccountSettings -> AccountSettingsScreen( state = state.account, onChange = { updated -> - viewModel.onEvent(ProfileEvent.AccountSettingsChanged( - fullName = updated.fullName, - email = updated.email, - phone = updated.phone, - password = updated.password - )) + viewModel.onEvent( + ProfileEvent.AccountSettingsChanged( + fullName = updated.fullName, + email = updated.email, + phone = updated.phone, + password = updated.password + ) + ) }, onSave = { viewModel.onEvent(ProfileEvent.SaveAccountSettings) }, onDelete = { viewModel.onEvent(ProfileEvent.DeleteAccount) }, onBack = { currentPage = ProfilePage.Overview } ) - ProfilePage.Preferences -> PreferencesScreen( prefs = state.preferences, onChange = { newPrefs -> viewModel.onEvent( ProfileEvent.PreferencesChanged( - notificationsEnabled = newPrefs.notificationsEnabled, - emailUpdatesEnabled = newPrefs.emailUpdatesEnabled, - darkModeEnabled = newPrefs.darkModeEnabled + notificationsEnabled = newPrefs.notificationsEnabled, + emailUpdatesEnabled = newPrefs.emailUpdatesEnabled, + darkModeEnabled = newPrefs.darkModeEnabled ) ) }, @@ -88,10 +92,10 @@ fun ProfileRoute( onChange = { updated -> viewModel.onEvent( ProfileEvent.PrivacySafetyChanged( - showProfilePublicly = updated.showProfilePublicly, + showProfilePublicly = updated.showProfilePublicly, allowMessagesFromNonContacts = updated.allowMessagesFromNonContacts, - shareTripHistoryWithFriends = updated.shareTripHistoryWithFriends, - twoFactorEnabled = updated.twoFactorEnabled + shareTripHistoryWithFriends = updated.shareTripHistoryWithFriends, + twoFactorEnabled = updated.twoFactorEnabled ) ) }, @@ -99,7 +103,6 @@ fun ProfileRoute( onBack = { currentPage = ProfilePage.Overview }, modifier = modifier ) - } } diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileViewModel.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileViewModel.kt index 0d3397a..6d063f1 100644 --- a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileViewModel.kt +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/profile/ProfileViewModel.kt @@ -7,7 +7,7 @@ import com.example.demo.feature.profile.data.InMemoryProfileRepository import com.example.demo.feature.profile.data.ProfileRepository class ProfileViewModel( - private val repository: ProfileRepository = InMemoryProfileRepository() + private val repository: ProfileRepository ) { private val _uiState = MutableStateFlow(repository.loadInitialProfile()) @@ -180,37 +180,65 @@ class ProfileViewModel( ) } - private fun saveVehicle() { + fun saveVehicle() { val state = _uiState.value val edit = state.vehicleEdit - val seats = edit.seatsTotal.toIntOrNull() ?: 0 - val year = edit.year.toIntOrNull() ?: 0 + // --- Enforce DB constraints safely --- + + // Seats: parse, clamp 1..8, default to 4 if blank/bad + val seats = edit.seatsTotal + .toIntOrNull() + ?.coerceIn(1, 8) + ?: 4 + + // Year: parse, clamp 1900..2100, default to 2024 if blank/bad + val safeYear = edit.year + .toIntOrNull() + ?.coerceIn(1900, 2100) + ?: 2024 + + // Required strings: make sure they are not blank + val safeMake = edit.make.ifBlank { "Unknown" } + val safeModel = edit.model.ifBlank { "Car" } + val safeColor = edit.color.ifBlank { "Unknown" } + val basePlate = edit.plate.ifBlank { "TEMP" } val updatedVehicles = if (edit.id == null) { - val newId = (state.vehicles.maxOfOrNull { it.id } ?: 0) + 1 + // New vehicle → CREATE + // Use negative IDs so we never collide with existing positive DB IDs + val currentMinId = state.vehicles.minOfOrNull { it.id } ?: 0 + val newId = if (currentMinId > 0) -1 else currentMinId - 1 + + // Make sure plate is unique-ish if user left it blank + val safePlate = if (edit.plate.isBlank()) { + "$basePlate-$newId" // <-- use whatever base string you defined above + } else { + edit.plate + } state.vehicles + VehicleUi( id = newId, - ownerUserId = edit.ownerUserId ?: 1, - make = edit.make, - model = edit.model, - color = edit.color, - plate = edit.plate, + ownerUserId = edit.ownerUserId ?: 1, // current user id placeholder + make = safeMake, + model = safeModel, + color = safeColor, + plate = safePlate, seatsTotal = seats, - year = year, + year = safeYear, funFact = edit.funFact ) } else { + // Existing vehicle → UPDATE state.vehicles.map { v -> if (v.id == edit.id) { v.copy( - make = edit.make, - model = edit.model, - color = edit.color, - plate = edit.plate, + make = safeMake, + model = safeModel, + color = safeColor, + plate = if (edit.plate.isBlank()) v.plate else edit.plate, seatsTotal = seats, - year = year, + year = safeYear, funFact = edit.funFact ) } else v diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/AvailableOffersScreen.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/AvailableOffersScreen.kt new file mode 100644 index 0000000..58d8143 --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/AvailableOffersScreen.kt @@ -0,0 +1,352 @@ +package com.example.demo.feature.rides + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.demo.ui.theme.DrexelBlue +import com.example.demo.ui.theme.DrexelGold +import com.example.demo.ui.theme.FieldBackground +import com.example.demo.ui.theme.HintGrey + +@Composable +fun AvailableOfferScreen( + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onPublish: () -> Unit = {} +) { + var origin by remember { mutableStateOf("University Crossings") } + var destination by remember { mutableStateOf("Korman Center") } + var departure by remember { mutableStateOf("") } + + var selectedVehicle by remember { mutableStateOf("Tesla Model Y (Blue)") } + val vehicleOptions = listOf( + "Tesla Model Y (Blue)", + "Honda Civic (Black)", + "Toyota Camry (Silver)" + ) + + var selectedSeats by remember { mutableStateOf("2 Seats") } + val seatOptions = listOf("1 Seat", "2 Seats", "3 Seats", "4 Seats") + + var basePrice by remember { mutableStateOf("5.00") } + var perMilePrice by remember { mutableStateOf("0.50") } + + var scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .background(FieldBackground) + .verticalScroll(scrollState) + ) { + // ---------- HEADER ---------- + Box( + modifier = Modifier + .fillMaxWidth() + .background(DrexelBlue) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp) + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Offer a Ride", + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Share your journey details", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.8f) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // ---------- MAIN CARD ---------- + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Origin + LabeledIconField( + label = "Origin Location", + value = origin, + onValueChange = { origin = it }, + icon = Icons.Default.Place, + placeholder = "University Crossings" + ) + + // Destination + LabeledIconField( + label = "Destination", + value = destination, + onValueChange = { destination = it }, + icon = Icons.Default.Place, + placeholder = "Korman Center" + ) + + // Departure Time + LabeledIconField( + label = "Departure Time", + value = departure, + onValueChange = { departure = it }, + icon = Icons.Default.AccessTime, + placeholder = "mm/dd/yyyy --:-- --" + ) + + // Vehicle dropdown + LabeledDropdownField( + label = "Choose Vehicle", + value = selectedVehicle, + onValueChange = { selectedVehicle = it }, + options = vehicleOptions, + leadingIcon = Icons.Default.DirectionsCar + ) + + // Seats dropdown + LabeledDropdownField( + label = "Available Seats", + value = selectedSeats, + onValueChange = { selectedSeats = it }, + options = seatOptions, + leadingIcon = Icons.Default.People + ) + + // Prices row + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + LabeledPriceField( + label = "Base Price", + value = basePrice, + onValueChange = { basePrice = it }, + modifier = Modifier.weight(1f) + ) + + LabeledPriceField( + label = "Per Mile", + value = perMilePrice, + onValueChange = { perMilePrice = it }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Publish button + Button( + onClick = onPublish, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = DrexelGold, + contentColor = DrexelBlue + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Publish Offer", + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +/* ---------- Reusable components used on the screen ---------- */ + +@Composable +private fun FieldLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = DrexelBlue, + modifier = Modifier.padding(bottom = 4.dp) + ) +} + +@Composable +private fun LabeledIconField( + label: String, + value: String, + onValueChange: (String) -> Unit, + icon: androidx.compose.ui.graphics.vector.ImageVector, + placeholder: String +) { + Column { + FieldLabel(label) + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + tint = HintGrey + ) + }, + placeholder = { Text(text = placeholder, color = HintGrey) }, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color(0xFFF5F6F8), + unfocusedContainerColor = Color(0xFFF5F6F8), + disabledContainerColor = Color(0xFFF5F6F8), + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTextColor = DrexelBlue, + unfocusedTextColor = DrexelBlue + ), + singleLine = true + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LabeledDropdownField( + label: String, + value: String, + onValueChange: (String) -> Unit, + options: List, + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector +) { + var expanded by remember { mutableStateOf(false) } + + Column { + FieldLabel(label) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + leadingIcon = { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = HintGrey + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color(0xFFF5F6F8), + unfocusedContainerColor = Color(0xFFF5F6F8), + disabledContainerColor = Color(0xFFF5F6F8), + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTextColor = DrexelBlue, + unfocusedTextColor = DrexelBlue + ), + singleLine = true + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onValueChange(option) + expanded = false + } + ) + } + } + } + } +} + +@Composable +private fun LabeledPriceField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + FieldLabel(label) + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Text( + text = "$", + color = HintGrey, + fontWeight = FontWeight.SemiBold + ) + }, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color(0xFFF5F6F8), + unfocusedContainerColor = Color(0xFFF5F6F8), + disabledContainerColor = Color(0xFFF5F6F8), + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTextColor = DrexelBlue, + unfocusedTextColor = DrexelBlue + ), + singleLine = true + ) + } +} diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/AvailableRidesScreen.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/AvailableRidesScreen.kt new file mode 100644 index 0000000..b43a64c --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/AvailableRidesScreen.kt @@ -0,0 +1,224 @@ +package com.example.demo.feature.rides + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.demo.ui.theme.DrexelBlue +import com.example.demo.ui.theme.DrexelGold +import com.example.demo.ui.theme.HintGrey +import com.example.demo.ui.theme.FieldBackground +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.Icon + +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch +import com.example.demo.feature.db.RideOffer +import com.example.demo.feature.db.RideRepository + +@Composable +fun AvailableRidesScreen( + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + rideRepository: RideRepository +) { + val coroutineScope = rememberCoroutineScope() + + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var rideOffers by remember { mutableStateOf>(emptyList()) } + + // load from DB on first composition + LaunchedEffect(Unit) { + try { + isLoading = true + errorMessage = null + rideOffers = rideRepository.getOpenRideOffers() + } catch (e: Exception) { + errorMessage = e.message ?: "Failed to load rides" + } finally { + isLoading = false + } + } + + // simple “best / other” split (first 3 vs rest) + val bestMatches: List = + if (rideOffers.size > 3) rideOffers.take(3) else rideOffers + val otherMatches: List = + if (rideOffers.size > 3) rideOffers.drop(3) else emptyList() + + Column( + modifier = modifier + .fillMaxSize() + .background(FieldBackground) + ) { + // your existing header / back button code stays the same + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = DrexelBlue) + } + return@Column + } + + if (errorMessage != null) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = errorMessage!!, + color = Color.Red + ) + } + return@Column + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (bestMatches.isNotEmpty()) { + item { + SectionBadge( + text = "Best Matches", + color = DrexelGold, + textColor = DrexelBlue + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + items(bestMatches) { offer -> + RideCard(offer = offer) + } + } + + if (otherMatches.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(16.dp)) + SectionBadge( + text = "Other Matches", + color = Color.White, + textColor = HintGrey, + isBordered = true + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + items(otherMatches) { offer -> + RideCard(offer = offer) + } + } + } + } +} + + + +@Composable +fun RideCard(offer: RideOffer) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // You don't currently join driver name / rating / car model in RideOffer, + // so we’ll show what we *do* have and use placeholders where needed. + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Driver #${offer.driverId}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = DrexelBlue + ) + Text( + text = "Vehicle #${offer.vehicleId}", + style = MaterialTheme.typography.bodySmall, + color = HintGrey + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = "$${"%.2f".format(offer.priceBase)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = DrexelBlue + ) + Text( + text = "${offer.seatsAvailable} seats", + style = MaterialTheme.typography.bodySmall, + color = HintGrey + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "${offer.fromName} → ${offer.toName}", + style = MaterialTheme.typography.bodyMedium, + color = DrexelBlue + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = offer.departAt, + style = MaterialTheme.typography.bodyMedium, + color = DrexelBlue + ) + } + } +} + +@Composable +fun SectionBadge( + text: String, + color: Color, + textColor: Color, + isBordered: Boolean = false +) { + Surface( + color = color, + shape = RoundedCornerShape(50), + border = if (isBordered) null else null, + modifier = Modifier.wrapContentSize() + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = textColor + ) + } +} diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/FieldInputs.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/FieldInputs.kt new file mode 100644 index 0000000..baf2f79 --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/FieldInputs.kt @@ -0,0 +1,80 @@ +package com.example.demo.feature.rides + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.demo.ui.theme.DrexelBlue +import com.example.demo.ui.theme.HintGrey + +@Composable +fun RideInput( + label: String, + icon: ImageVector, + placeholder: String, + modifier: Modifier = Modifier +) { + var textState by remember { mutableStateOf("") } + val fieldColor = Color(0xFFF3F4F6) + + Column(modifier = modifier) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = DrexelBlue, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 4.dp) + ) + + OutlinedTextField( + value = textState, + onValueChange = { textState = it }, + modifier = Modifier.fillMaxWidth(), + + placeholder = { + Text( + text = placeholder, + color = HintGrey + ) + }, + + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + tint = HintGrey, + modifier = Modifier.size(28.dp) + ) + }, + + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = fieldColor, + unfocusedContainerColor = fieldColor, + disabledContainerColor = fieldColor, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTextColor = DrexelBlue, + unfocusedTextColor = DrexelBlue + ), + singleLine = true, + ) + } +} \ No newline at end of file diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/FindRideScreen.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/FindRideScreen.kt new file mode 100644 index 0000000..83313d1 --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/FindRideScreen.kt @@ -0,0 +1,144 @@ +package com.example.demo.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.demo.feature.rides.RideInput +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.Icon + +@Composable +fun FindRideScreen() { + val bgColor = Color(0xFFF3F4F6) + val scrollState = rememberScrollState() + + Box( + modifier = Modifier + .fillMaxSize() + .background(bgColor) + ) { + // 1. Header Section + Column( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .background(DrexelBlue) + .padding(24.dp) + ) { + IconButton(onClick = { /* TODO: handle back nav */ }) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Find a Ride", + style = MaterialTheme.typography.titleLarge, + color = Color.White + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Enter your trip details", + style = MaterialTheme.typography.titleLarge, + color = HintGrey + ) + } + + // 2. The Floating Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 175.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + .verticalScroll(scrollState), // Make form scrollable on small screens + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Locations + RideInput( + label = "Pickup Location", + icon = Icons.Filled.LocationOn, + placeholder = "30th Street Station" + ) + RideInput( + label = "Drop-off Location", + icon = Icons.Filled.LocationOn, + placeholder = "Cira Green" + ) + + // Date & Time + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RideInput( + label = "Date", + icon = Icons.Filled.CalendarMonth, + placeholder = "mm/dd/yyyy", + modifier = Modifier.weight(1f) + ) + RideInput( + label = "Time", + icon = Icons.Filled.AccessTime, + placeholder = "--:-- --", + modifier = Modifier.weight(1f) + ) + } + + // Seats & Price + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RideInput( + label = "Seats Needed", + icon = Icons.Filled.Group, + placeholder = "2", + modifier = Modifier.weight(1f) + ) + RideInput( + label = "Max Price", + icon = Icons.Filled.AttachMoney, + placeholder = "20", + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Search Button + Button( + onClick = { /* TODO: trigger search */ }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = DrexelGold, + contentColor = DrexelBlue + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Search for Rides", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + } + } + } + } +} diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/OfferRideScreen.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/OfferRideScreen.kt new file mode 100644 index 0000000..815342f --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/OfferRideScreen.kt @@ -0,0 +1,147 @@ +package com.example.demo.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.demo.feature.rides.RideInput + +@Composable +fun OfferRideScreen( + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onPublish: () -> Unit = {} +) { + val bgColor = Color(0xFFF3F4F6) + + Column( + modifier = modifier + .fillMaxSize() + .background(bgColor) + ) { + // ---------- HEADER (smaller) ---------- + Column( + modifier = Modifier + .fillMaxWidth() + .background(DrexelBlue) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Text( + text = "Offer a Ride", + style = MaterialTheme.typography.titleMedium, // smaller + color = Color.White + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Share your journey details", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // ---------- CARD ---------- + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) // tighter spacing + ) { + // Origin & Destination + RideInput( + label = "Origin", + icon = Icons.Filled.LocationOn, + placeholder = "University Crossings" + ) + RideInput( + label = "Destination", + icon = Icons.Filled.LocationOn, + placeholder = "Korman Center" + ) + + // Time + Seats (2 per row to save height) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RideInput( + label = "Time", + icon = Icons.Filled.AccessTime, + placeholder = "mm/dd hh:mm", + modifier = Modifier.weight(1f) + ) + RideInput( + label = "Seats", + icon = Icons.Filled.Group, + placeholder = "2", + modifier = Modifier.weight(1f) + ) + } + + // Vehicle + Price (2 per row) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RideInput( + label = "Vehicle", + icon = Icons.Filled.DirectionsCar, + placeholder = "Model Y", + modifier = Modifier.weight(1f) + ) + RideInput( + label = "Price", + icon = Icons.Filled.AttachMoney, + placeholder = "5.00", + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // ---------- BUTTON (always visible) ---------- + Button( + onClick = onPublish, + modifier = Modifier + .fillMaxWidth() + .height(44.dp), // a bit shorter + colors = ButtonDefaults.buttonColors( + containerColor = DrexelGold, + contentColor = DrexelBlue + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Publish Offer", + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp // smaller + ) + } + } + } + + // tiny spacer so it doesn’t touch bottom nav + Spacer(modifier = Modifier.height(8.dp)) + } +} diff --git a/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/RideOption.kt b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/RideOption.kt new file mode 100644 index 0000000..f50636d --- /dev/null +++ b/app/composeApp/src/commonMain/kotlin/com/example/demo/feature/rides/RideOption.kt @@ -0,0 +1,14 @@ +package com.example.demo.feature.rides + +// Simple data class to hold ride info +data class RideOption( + val driverName: String, + val rating: Double, + val price: Double, + val seats: Int, + val carModel: String, + val pickup: String, + val dropoff: String, + val time: String, + val isBestMatch: Boolean = false +) \ No newline at end of file diff --git a/docs/dev_journal/Kennan.md b/docs/dev_journal/Kennan.md index a772d0d..01e91bf 100644 --- a/docs/dev_journal/Kennan.md +++ b/docs/dev_journal/Kennan.md @@ -1,3 +1,52 @@ +# 11/20/2025 + +Experienced a bunch of issues with trying to build and run the app. + +Solution: Deleted the .gradle folder in the app subdirectory and forced it to be rebuilt. + +# 11/16/2025 + +## Development Strategy + +As we team we focused and came up with the strategy with 3 Phases. + +We are following Three Phase Strategy: + +UI Development -> UI Connection Development -> Frontend to Backend Database Connection + +And [ChatGPT](https://chatgpt.com/share/691a8a83-69c0-800e-be1c-aa304a8a901f) verified this is the correct process that modern companies use. + +Following paging structure with react where we have folders for our features. + +## Importing Icons + +You should use your own SVG/XML files if you want to import icons onto the UI for the screens. + + 1. Get your icons - Download the icons you want as .svg or .xml (Android Vector) files. (Sites like Heroicons or Phosphor Icons are good sources). + + 2. Place them in the resources folder Go to composeApp/src/commonMain/composeResources/drawable. Paste your files there (e.g., ic_calendar.xml, ic_location.xml). + + 3. Update the code to use painterResource + +EXAMPLE CALL using painterResource: +icon = painterResource(Res.drawable.ic_calendar) + +#### 11/21/2025 Update: ANDROID DOES NOT SUPPORT SVG FILES + +Here's how to convert them to XML before use: + + 1. In the Project view (left panel), right-click on your commonMain/composeResources/drawable folder. + + 2. Select New > Vector Asset. + + 3. In the "Asset Type" section, choose Local file (SVG, PSD). + + 4. Click the folder icon next to Path and select your original SVG file. + + 5. Click Next and then Finish. + +This will create a new .xml file in that folder. Delete the old .svg file so there is no confusion. + # 11/2/2025 - Initialize the SQLite Database diff --git a/identifier.sqlite b/identifier.sqlite new file mode 100644 index 0000000..e69de29