diff --git a/ai_scan.py b/ai_scan.py new file mode 100644 index 00000000..73ae04f7 --- /dev/null +++ b/ai_scan.py @@ -0,0 +1,146 @@ +""" +AI detection scanner using HuggingFace Inference API (free). +Model: Hello-SimpleAI/chatgpt-detector-roberta + +Usage: + python ai_scan.py [folder2 ...] + +Example: + python ai_scan.py hf_abc123 lib/features/messaging lib/features/settings + +Get a free token at: huggingface.co/settings/tokens +""" + +import sys +import time +import requests +from pathlib import Path + +API_URL = "https://api-inference.huggingface.co/models/Hello-SimpleAI/chatgpt-detector-roberta" +EXTENSIONS = {".dart", ".kt", ".swift", ".java"} +MAX_CHARS = 512 # RoBERTa token limit (keep to ~500 chars to be safe) +RATE_DELAY = 1.5 # seconds between requests + + +def scan_file(api_key: str, file_path: Path) -> float | None: + text = file_path.read_text(encoding="utf-8", errors="ignore").strip() + if len(text) < 80: + return None # too short to be meaningful + + # Truncate to model limit + if len(text) > MAX_CHARS: + text = text[:MAX_CHARS] + + try: + resp = requests.post( + API_URL, + headers={"Authorization": f"Bearer {api_key}"}, + json={"inputs": text}, + timeout=30, + ) + if resp.status_code == 503: + # Model is loading — wait and retry once + print(" model loading, waiting 20s...") + time.sleep(20) + resp = requests.post( + API_URL, + headers={"Authorization": f"Bearer {api_key}"}, + json={"inputs": text}, + timeout=30, + ) + resp.raise_for_status() + + # Response: [[{"label": "ChatGPT", "score": 0.87}, {"label": "Human", "score": 0.13}]] + labels = resp.json()[0] + ai_score = next((x["score"] for x in labels if x["label"].lower() != "human"), None) + if ai_score is None: + # fallback: 1 - human score + human = next((x["score"] for x in labels if x["label"].lower() == "human"), 0) + ai_score = 1 - human + return round(ai_score * 100, 1) + + except requests.HTTPError as e: + if resp.status_code == 429: + print(" rate limited — waiting 60s...") + time.sleep(60) + else: + print(f" HTTP {resp.status_code}: {e}") + return None + except Exception as e: + print(f" error: {e}") + return None + + +def scan_folder(api_key: str, folder: str) -> list[tuple[str, float]]: + root = Path(folder) + if not root.exists(): + print(f"Folder not found: {folder}") + return [] + + files = sorted( + f for f in root.rglob("*") if f.suffix in EXTENSIONS and f.is_file() + ) + if not files: + print(f"No supported files found in {folder}") + return [] + + print(f"\nScanning {len(files)} files in '{folder}'\n") + col = 68 + print(f"{'File':<{col}} {'AI %':>6}") + print("─" * (col + 8)) + + results = [] + for i, fp in enumerate(files): + label = str(fp.relative_to(root.parent)) + pct = scan_file(api_key, fp) + + if pct is not None: + results.append((label, pct)) + print(f"{label:<{col}} {pct:>5.1f}%") + else: + print(f"{label:<{col}} {'skip':>6}") + + if i < len(files) - 1: + time.sleep(RATE_DELAY) + + return results + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + sys.exit(1) + + api_key = sys.argv[1] + folders = sys.argv[2:] + + all_results: list[tuple[str, float]] = [] + for folder in folders: + all_results.extend(scan_folder(api_key, folder)) + + if not all_results: + print("Nothing scanned.") + return + + col = 68 + print("\n" + "═" * (col + 8)) + print(f"{'SUMMARY':<{col}}") + print("═" * (col + 8)) + + all_results.sort(key=lambda x: x[1], reverse=True) + for label, pct in all_results: + bar = "█" * int(pct / 5) + print(f"{label:<{col}} {pct:>5.1f}% {bar}") + + avg = sum(p for _, p in all_results) / len(all_results) + print("─" * (col + 8)) + print(f"\n Overall average : {avg:.1f}% AI") + print(f" Files scanned : {len(all_results)}") + highest = max(all_results, key=lambda x: x[1]) + lowest = min(all_results, key=lambda x: x[1]) + print(f" Highest : {highest[1]:.1f}% ({highest[0]})") + print(f" Lowest : {lowest[1]:.1f}% ({lowest[0]})\n") + + +if __name__ == "__main__": + main() diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c0dad72a..5dbf33a6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -78,4 +78,5 @@ flutter { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cb6e0615..fd0b1ca8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -70,6 +70,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/rythmify/LikesWidget.kt b/android/app/src/main/kotlin/com/example/rythmify/LikesWidget.kt new file mode 100644 index 00000000..38c8a100 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/rythmify/LikesWidget.kt @@ -0,0 +1,250 @@ +package com.example.rythmify + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.View +import android.widget.RemoteViews +import es.antonborri.home_widget.HomeWidgetLaunchIntent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URL + +class LikesWidget : AppWidgetProvider() { + + enum class LayoutType { ROW, FEATURE, MINI } + + companion object { + private const val PREFS_NAME = "HomeWidgetPreferences" + const val MAX_TRACKS = 5 + + private val TRACK_VIEW_IDS = listOf( + R.id.likes_track_0, R.id.likes_track_1, R.id.likes_track_2, + R.id.likes_track_3, R.id.likes_track_4 + ) + + fun triggerUpdate(context: Context) { + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds(ComponentName(context, LikesWidget::class.java)) + if (ids.isEmpty()) return + val intent = Intent(context, LikesWidget::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + } + context.sendBroadcast(intent) + } + + fun updateWidget( + context: Context, + manager: AppWidgetManager, + widgetId: Int, + // MAX values = actual available space; defaults assume a medium-size placement + width: Int = 300, + height: Int = 150 + ) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val trackCount = minOf(prefs.getInt("likes_track_count", 0), MAX_TRACKS) + val userId = prefs.getString("likes_user_id", "") ?: "" + val pfpUrl = prefs.getString("likes_pfp_url", null) + + val layout = chooseLayout(width, height) + val views = when (layout) { + LayoutType.MINI -> buildMiniViews(context, prefs, trackCount) + LayoutType.ROW -> buildRowViews(context, prefs, trackCount, userId, width) + LayoutType.FEATURE -> buildFeatureViews(context, prefs, trackCount, userId) + } + + manager.updateAppWidget(widgetId, views) + + // Async: fetch bitmaps and re-draw + CoroutineScope(Dispatchers.IO).launch { + val pfpBitmap = if (!pfpUrl.isNullOrBlank()) loadCircleBitmap(pfpUrl) else null + val artUrls = (0 until trackCount).map { i -> + prefs.getString("likes_track_${i}_art", null) + } + val artBitmaps = artUrls.map { url -> + if (!url.isNullOrBlank()) loadBitmap(url) else null + } + + withContext(Dispatchers.Main) { + when (layout) { + LayoutType.ROW -> { + pfpBitmap?.let { views.setImageViewBitmap(R.id.likes_pfp, it) } + val n = tracksToShow(width, trackCount) + artBitmaps.forEachIndexed { i, bmp -> + if (i < n && bmp != null) { + views.setImageViewBitmap(TRACK_VIEW_IDS[i], bmp) + } + } + } + LayoutType.FEATURE -> { + pfpBitmap?.let { views.setImageViewBitmap(R.id.likes_pfp, it) } + artBitmaps.firstOrNull()?.let { + views.setImageViewBitmap(R.id.likes_feature_art, it) + } + } + LayoutType.MINI -> { + artBitmaps.firstOrNull()?.let { + views.setImageViewBitmap(R.id.likes_mini_art, it) + } + } + } + manager.updateAppWidget(widgetId, views) + } + } + } + + // width/height = MAX dimensions (available space) from the options bundle. + // ROW: compact height (1 cell ≈ 60-100dp). MINI: narrow width (1 cell ≈ 60-100dp). + private fun chooseLayout(width: Int, height: Int): LayoutType = when { + width < 130 -> LayoutType.MINI + height < 130 -> LayoutType.ROW + else -> LayoutType.FEATURE + } + + // On a 360dp screen the 4-cell full-width widget is ~288dp — well under 300. + // Lower thresholds to match real device grid sizes. + private fun tracksToShow(width: Int, totalCount: Int): Int { + val maxByWidth = when { + width >= 250 -> 5 + width >= 160 -> 2 + else -> 1 + } + return minOf(maxByWidth, totalCount) + } + + private fun buildRowViews( + context: Context, + prefs: android.content.SharedPreferences, + trackCount: Int, + userId: String, + width: Int + ): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.likes_widget_row) + val n = tracksToShow(width, trackCount) + + // views.setOnClickPendingIntent(R.id.likes_root, openLikesIntent(context)) + // views.setOnClickPendingIntent(R.id.likes_pfp, openProfileIntent(context, userId)) + + TRACK_VIEW_IDS.forEachIndexed { i, viewId -> + if (i < n) { + views.setViewVisibility(viewId, View.VISIBLE) + // val trackId = prefs.getString("likes_track_${i}_id", null) + // views.setOnClickPendingIntent(viewId, trackIntent(context, trackId)) + } else { + views.setViewVisibility(viewId, View.GONE) + } + } + + return views + } + + private fun buildFeatureViews( + context: Context, + prefs: android.content.SharedPreferences, + trackCount: Int, + userId: String + ): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.likes_widget_feature) + + // views.setOnClickPendingIntent(R.id.likes_root, openLikesIntent(context)) + // views.setOnClickPendingIntent(R.id.likes_pfp, openProfileIntent(context, userId)) + + // val trackId = if (trackCount > 0) prefs.getString("likes_track_0_id", null) else null + // views.setOnClickPendingIntent(R.id.likes_feature_art, trackIntent(context, trackId)) + + return views + } + + private fun buildMiniViews( + context: Context, + prefs: android.content.SharedPreferences, + trackCount: Int + ): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.likes_widget_mini) + + // views.setOnClickPendingIntent(R.id.likes_root, openLikesIntent(context)) + + // val trackId = if (trackCount > 0) prefs.getString("likes_track_0_id", null) else null + // views.setOnClickPendingIntent(R.id.likes_mini_art, trackIntent(context, trackId)) + + return views + } + + private fun openLikesIntent(context: Context): PendingIntent = + HomeWidgetLaunchIntent.getActivity( + context, MainActivity::class.java, + Uri.parse("rythmify://likes_open") + ) + + private fun openProfileIntent(context: Context, userId: String): PendingIntent = + HomeWidgetLaunchIntent.getActivity( + context, MainActivity::class.java, + Uri.parse("rythmify://likes_profile?userId=${Uri.encode(userId)}") + ) + + private fun trackIntent(context: Context, trackId: String?): PendingIntent { + val uri = if (!trackId.isNullOrBlank()) + Uri.parse("rythmify://likes_track?id=${Uri.encode(trackId)}") + else Uri.parse("rythmify://likes_open") + return HomeWidgetLaunchIntent.getActivity(context, MainActivity::class.java, uri) + } + + private fun loadBitmap(url: String): Bitmap? = try { + BitmapFactory.decodeStream(URL(url).openStream()) + } catch (_: Exception) { + null + } + + private fun loadCircleBitmap(url: String): Bitmap? { + val bmp = loadBitmap(url) ?: return null + val size = minOf(bmp.width, bmp.height) + val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + val scaled = Bitmap.createScaledBitmap(bmp, size, size, true) + canvas.drawBitmap(scaled, 0f, 0f, paint) + return output + } + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + appWidgetIds.forEach { id -> + val opts = appWidgetManager.getAppWidgetOptions(id) + val w = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 300) + val h = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 150) + updateWidget(context, appWidgetManager, id, w, h) + } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle + ) { + val w = newOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 300) + val h = newOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 150) + updateWidget(context, appWidgetManager, appWidgetId, w, h) + } +} diff --git a/android/app/src/main/kotlin/com/example/rythmify/MainActivity.kt b/android/app/src/main/kotlin/com/example/rythmify/MainActivity.kt index deaee780..610f36f6 100644 --- a/android/app/src/main/kotlin/com/example/rythmify/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/rythmify/MainActivity.kt @@ -1,7 +1,9 @@ package com.example.rythmify +import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.pm.PackageManager +import android.os.Build import com.ryanheise.audioservice.AudioServiceActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -42,6 +44,34 @@ class MainActivity : AudioServiceActivity() { result.error("ERROR", e.message, null) } } + "pinWidget" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = AppWidgetManager.getInstance(this) + if (manager.isRequestPinAppWidgetSupported) { + val component = ComponentName(this, PlayerWidget::class.java) + manager.requestPinAppWidget(component, null, null) + result.success(true) + } else { + result.success(false) + } + } else { + result.success(false) + } + } + "pinLikesWidget" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = AppWidgetManager.getInstance(this) + if (manager.isRequestPinAppWidgetSupported) { + val component = ComponentName(this, LikesWidget::class.java) + manager.requestPinAppWidget(component, null, null) + result.success(true) + } else { + result.success(false) + } + } else { + result.success(false) + } + } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/com/example/rythmify/PlayerWidget.kt b/android/app/src/main/kotlin/com/example/rythmify/PlayerWidget.kt new file mode 100644 index 00000000..70eafe54 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/rythmify/PlayerWidget.kt @@ -0,0 +1,137 @@ +package com.example.rythmify + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.view.KeyEvent +import android.widget.RemoteViews +import es.antonborri.home_widget.HomeWidgetLaunchIntent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URL + +class PlayerWidget : AppWidgetProvider() { + + companion object { + const val ACTION_PREV = "com.example.rythmify.WIDGET_PREV" + const val ACTION_PLAY_PAUSE = "com.example.rythmify.WIDGET_PLAY_PAUSE" + const val ACTION_NEXT = "com.example.rythmify.WIDGET_NEXT" + + // home_widget writes to this SharedPreferences file + private const val PREFS_NAME = "HomeWidgetPreferences" + + fun triggerUpdate(context: Context) { + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds(ComponentName(context, PlayerWidget::class.java)) + if (ids.isEmpty()) return + val intent = Intent(context, PlayerWidget::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + } + context.sendBroadcast(intent) + } + + fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val title = prefs.getString("track_title", "No track playing") ?: "No track playing" + val artist = prefs.getString("artist_name", "") ?: "" + val isPlaying = prefs.getBoolean("is_playing", false) + val isLiked = prefs.getBoolean("is_liked", false) + val artUrl = prefs.getString("art_url", null) + + val views = RemoteViews(context.packageName, R.layout.player_widget) + views.setTextViewText(R.id.widget_artist, artist) + views.setTextViewText(R.id.widget_title, title) + views.setImageViewResource( + R.id.widget_btn_play_pause, + if (isPlaying) R.drawable.ic_widget_pause else R.drawable.ic_widget_play + ) + views.setImageViewResource( + R.id.widget_btn_like, + if (isLiked) R.drawable.ic_favorite else R.drawable.ic_favorite_border + ) + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + else + PendingIntent.FLAG_UPDATE_CURRENT + + views.setOnClickPendingIntent(R.id.widget_btn_prev, broadcast(context, ACTION_PREV, flags)) + views.setOnClickPendingIntent(R.id.widget_btn_play_pause, broadcast(context, ACTION_PLAY_PAUSE, flags)) + views.setOnClickPendingIntent(R.id.widget_btn_next, broadcast(context, ACTION_NEXT, flags)) + + val likeIntent = HomeWidgetLaunchIntent.getActivity( + context, + MainActivity::class.java, + Uri.parse("rythmify://widget?action=like") + ) + views.setOnClickPendingIntent(R.id.widget_btn_like, likeIntent) + + val openPlayerIntent = HomeWidgetLaunchIntent.getActivity( + context, + MainActivity::class.java, + Uri.parse("rythmify://player") + ) + views.setOnClickPendingIntent(R.id.widget_album_art, openPlayerIntent) + views.setOnClickPendingIntent(R.id.widget_text_area, openPlayerIntent) + + manager.updateAppWidget(widgetId, views) + + // Async album art load — update again once the bitmap is ready + if (!artUrl.isNullOrBlank()) { + CoroutineScope(Dispatchers.IO).launch { + val bitmap = loadBitmap(artUrl) + if (bitmap != null) { + withContext(Dispatchers.Main) { + views.setImageViewBitmap(R.id.widget_album_art, bitmap) + manager.updateAppWidget(widgetId, views) + } + } + } + } + } + + private fun broadcast(context: Context, action: String, flags: Int): PendingIntent { + val intent = Intent(context, PlayerWidget::class.java).apply { this.action = action } + return PendingIntent.getBroadcast(context, action.hashCode(), intent, flags) + } + + private fun loadBitmap(url: String): Bitmap? = try { + BitmapFactory.decodeStream(URL(url).openStream()) + } catch (_: Exception) { + null + } + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + appWidgetIds.forEach { updateWidget(context, appWidgetManager, it) } + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + when (intent.action) { + ACTION_PREV -> mediaKey(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS) + ACTION_PLAY_PAUSE -> mediaKey(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + ACTION_NEXT -> mediaKey(context, KeyEvent.KEYCODE_MEDIA_NEXT) + } + } + + private fun mediaKey(context: Context, keyCode: Int) { + for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { + val intent = Intent(context, com.ryanheise.audioservice.MediaButtonReceiver::class.java).apply { + this.action = Intent.ACTION_MEDIA_BUTTON + putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(action, keyCode)) + } + context.sendBroadcast(intent) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_widget_pause.xml b/android/app/src/main/res/drawable/ic_widget_pause.xml new file mode 100644 index 00000000..d455dec8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_play.xml b/android/app/src/main/res/drawable/ic_widget_play.xml new file mode 100644 index 00000000..425a4e25 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_skip_next.xml b/android/app/src/main/res/drawable/ic_widget_skip_next.xml new file mode 100644 index 00000000..5fd8cb78 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_skip_previous.xml b/android/app/src/main/res/drawable/ic_widget_skip_previous.xml new file mode 100644 index 00000000..e9bac0bb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/likes_widget_background.xml b/android/app/src/main/res/drawable/likes_widget_background.xml new file mode 100644 index 00000000..4cbeba75 --- /dev/null +++ b/android/app/src/main/res/drawable/likes_widget_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..dbd405ca --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/layout/likes_widget_feature.xml b/android/app/src/main/res/layout/likes_widget_feature.xml new file mode 100644 index 00000000..3c684303 --- /dev/null +++ b/android/app/src/main/res/layout/likes_widget_feature.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/likes_widget_mini.xml b/android/app/src/main/res/layout/likes_widget_mini.xml new file mode 100644 index 00000000..142c71e7 --- /dev/null +++ b/android/app/src/main/res/layout/likes_widget_mini.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/likes_widget_row.xml b/android/app/src/main/res/layout/likes_widget_row.xml new file mode 100644 index 00000000..eb71b6e8 --- /dev/null +++ b/android/app/src/main/res/layout/likes_widget_row.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/player_widget.xml b/android/app/src/main/res/layout/player_widget.xml new file mode 100644 index 00000000..d78bd1bf --- /dev/null +++ b/android/app/src/main/res/layout/player_widget.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..aed7c4a6 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Control what\'s playing on Rythmify + Quickly access your latest liked tracks + diff --git a/android/app/src/main/res/xml/likes_widget_info.xml b/android/app/src/main/res/xml/likes_widget_info.xml new file mode 100644 index 00000000..ef3eea43 --- /dev/null +++ b/android/app/src/main/res/xml/likes_widget_info.xml @@ -0,0 +1,14 @@ + + diff --git a/android/app/src/main/res/xml/player_widget_info.xml b/android/app/src/main/res/xml/player_widget_info.xml new file mode 100644 index 00000000..10821000 --- /dev/null +++ b/android/app/src/main/res/xml/player_widget_info.xml @@ -0,0 +1,13 @@ + + diff --git a/lib/core/widget/likes_widget_sync_provider.dart b/lib/core/widget/likes_widget_sync_provider.dart new file mode 100644 index 00000000..c49fb152 --- /dev/null +++ b/lib/core/widget/likes_widget_sync_provider.dart @@ -0,0 +1,89 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_widget/home_widget.dart'; +import 'likes_widget_updater.dart'; +import '../../features/authentication/presentation/providers/auth_provider.dart'; +import '../../features/authentication/presentation/providers/auth_state.dart'; +import '../../features/library/presentation/providers/library_providers.dart'; +import '../routing/app_router.dart'; + +final likesWidgetSyncProvider = NotifierProvider( + LikesWidgetSyncNotifier.new, +); + +class LikesWidgetSyncNotifier extends Notifier { + @override + void build() { + // Push immediately if auth + likes are already ready (covers re-init after hot restart) + _maybePushCurrent(); + + ref.listen(likesProvider, (_, next) { + _push(next); + }); + + // When auth becomes ready AFTER likes have already loaded, push the cached state + ref.listen(authProvider, (_, next) { + if (next is AuthAuthenticated) { + _push(ref.read(likesProvider)); + } + }); + + // final sub = HomeWidget.widgetClicked.listen(_onWidgetAction); + // ref.onDispose(sub.cancel); + + // HomeWidget.initiallyLaunchedFromHomeWidget().then((uri) { + // if (uri != null) _onWidgetAction(uri); + // }); + } + + void _maybePushCurrent() { + final auth = ref.read(authProvider); + if (auth is! AuthAuthenticated) return; + final likes = ref.read(likesProvider); + if (likes.tracks.isNotEmpty) _push(likes); + } + + void _push(LikesState state) { + final authState = ref.read(authProvider); + if (authState is! AuthAuthenticated) return; + + final tracks = state.tracks + .take(5) + .map((t) => (id: t.id, artUrl: t.artworkUrl)) + .toList(); + + LikesWidgetUpdater.update( + tracks: tracks, + userId: authState.user.id, + pfpUrl: authState.user.avatarUrl, + ).ignore(); + } + + // void _onWidgetAction(Uri? uri) { + // if (uri == null) return; + // if (uri.scheme != 'rythmify') return; + + // // Ignore URIs meant for the player widget + // if (uri.host == 'widget' || uri.host == 'player') return; + + // final router = ref.read(routerProvider); + + // // go() is required here — push() targets the current branch's navigator, + // // which may not own routes in other StatefulShellBranch tabs. + // switch (uri.host) { + // case 'likes_open': + // router.go('/library/likes'); + + // case 'likes_profile': + // final userId = uri.queryParameters['userId']; + // if (userId != null && userId.isNotEmpty) { + // router.go('/home/profile/$userId'); + // } + + // case 'likes_track': + // final trackId = uri.queryParameters['id']; + // if (trackId != null && trackId.isNotEmpty) { + // router.go('/home/behind-the-track/$trackId'); + // } + // } + // } +} diff --git a/lib/core/widget/likes_widget_updater.dart b/lib/core/widget/likes_widget_updater.dart new file mode 100644 index 00000000..305e7313 --- /dev/null +++ b/lib/core/widget/likes_widget_updater.dart @@ -0,0 +1,30 @@ +import 'package:home_widget/home_widget.dart'; + +class LikesWidgetUpdater { + static const String _androidWidgetName = 'LikesWidget'; + + static Future update({ + required List<({String id, String? artUrl})> tracks, + required String userId, + String? pfpUrl, + }) async { + await HomeWidget.saveWidgetData('likes_track_count', tracks.length); + for (int i = 0; i < tracks.length && i < 5; i++) { + await HomeWidget.saveWidgetData( + 'likes_track_${i}_id', + tracks[i].id, + ); + if (tracks[i].artUrl != null) { + await HomeWidget.saveWidgetData( + 'likes_track_${i}_art', + tracks[i].artUrl!, + ); + } + } + await HomeWidget.saveWidgetData('likes_user_id', userId); + if (pfpUrl != null) { + await HomeWidget.saveWidgetData('likes_pfp_url', pfpUrl); + } + await HomeWidget.updateWidget(androidName: _androidWidgetName); + } +} diff --git a/lib/core/widget/player_widget_updater.dart b/lib/core/widget/player_widget_updater.dart new file mode 100644 index 00000000..dd6cf971 --- /dev/null +++ b/lib/core/widget/player_widget_updater.dart @@ -0,0 +1,26 @@ +import 'package:home_widget/home_widget.dart'; + +/// Pushes current player state to the Android home screen widget. +/// +/// Call [update] whenever the track or play state changes. +/// The widget reads the saved values when the OS triggers its next draw. +class PlayerWidgetUpdater { + static const String _androidWidgetName = 'PlayerWidget'; + + static Future update({ + required String trackTitle, + required String artistName, + required bool isPlaying, + required bool isLiked, + String? artUrl, + }) async { + await HomeWidget.saveWidgetData('track_title', trackTitle); + await HomeWidget.saveWidgetData('artist_name', artistName); + await HomeWidget.saveWidgetData('is_playing', isPlaying); + await HomeWidget.saveWidgetData('is_liked', isLiked); + if (artUrl != null) { + await HomeWidget.saveWidgetData('art_url', artUrl); + } + await HomeWidget.updateWidget(androidName: _androidWidgetName); + } +} diff --git a/lib/core/widget/widget_pin_service.dart b/lib/core/widget/widget_pin_service.dart new file mode 100644 index 00000000..680830a8 --- /dev/null +++ b/lib/core/widget/widget_pin_service.dart @@ -0,0 +1,25 @@ +import 'package:flutter/services.dart'; + +/// Asks the launcher to show the native "Add to Home screen?" pin dialog +/// for the player widget. Uses the same method channel as dynamic icons. +class WidgetPinService { + static const _channel = MethodChannel('flutter_dynamic_icon'); + + /// Returns `true` if the dialog was shown, `false` if the launcher does not + /// support widget pinning (older launchers or Android < 8). + static Future requestPin() async { + try { + return await _channel.invokeMethod('pinWidget') ?? false; + } on PlatformException { + return false; + } + } + + static Future requestPinLikes() async { + try { + return await _channel.invokeMethod('pinLikesWidget') ?? false; + } on PlatformException { + return false; + } + } +} diff --git a/lib/core/widget/widget_sync_provider.dart b/lib/core/widget/widget_sync_provider.dart new file mode 100644 index 00000000..b4c4a7e1 --- /dev/null +++ b/lib/core/widget/widget_sync_provider.dart @@ -0,0 +1,68 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:home_widget/home_widget.dart'; +import 'player_widget_updater.dart'; +import '../../features/player/presentation/providers/player_provider.dart'; +import '../../features/player/domain/entities/player_state.dart'; +import '../../features/track/presentation/providers/track_interaction_provider.dart'; +import '../routing/app_router.dart'; + +final widgetSyncProvider = NotifierProvider( + WidgetSyncNotifier.new, +); + +/// Keeps the home screen widget in sync with the app's player state and +/// handles actions sent back from the widget (like, open player). +class WidgetSyncNotifier extends Notifier { + @override + void build() { + ref.listen(playerStateProvider, (_, next) { + _push(next); + }); + + final sub = HomeWidget.widgetClicked.listen(_onWidgetAction); + ref.onDispose(sub.cancel); + + // Handle the URI if the app was cold-launched by tapping the widget + HomeWidget.initiallyLaunchedFromHomeWidget().then((uri) { + if (uri != null) _onWidgetAction(uri); + }); + } + + void _push(AppPlayerState playerState) { + final track = playerState.currentTrack; + if (track == null) return; + + PlayerWidgetUpdater.update( + trackTitle: track.title, + artistName: track.artist, + isPlaying: playerState.status == PlayerStatus.playing, + isLiked: track.isLiked, + artUrl: track.coverImage, + ).ignore(); + } + + void _onWidgetAction(Uri? uri) { + if (uri == null) return; + + if (uri.scheme == 'rythmify' && uri.host == 'widget') { + final action = uri.queryParameters['action']; + if (action == 'like') _handleLike(); + } else if (uri.scheme == 'rythmify' && uri.host == 'player') { + ref.read(routerProvider).push('/player'); + } + } + + void _handleLike() { + final track = ref.read(playerStateProvider).currentTrack; + if (track == null) return; + + // Optimistically flip the widget icon before the API responds + HomeWidget.saveWidgetData('is_liked', !track.isLiked).then((_) { + HomeWidget.updateWidget(androidName: 'PlayerWidget').ignore(); + }); + + ref + .read(trackInteractionProvider) + .handleToggleLike(track.id, track.isLiked, currentTrack: track); + } +} diff --git a/lib/features/feed/presentation/pages/home_screen.dart b/lib/features/feed/presentation/pages/home_screen.dart index c3014c8d..9ba15aa9 100644 --- a/lib/features/feed/presentation/pages/home_screen.dart +++ b/lib/features/feed/presentation/pages/home_screen.dart @@ -29,6 +29,8 @@ import '../../../../core/error/error_handler.dart'; // ── Added for artist name fix ── import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; +import 'package:rythmify/features/messaging/presentation/providers/unread_messages_count_provider.dart'; +import 'package:rythmify/features/notifications/presentation/providers/unread_count_provider.dart'; // 1. Temporary provider to fetch the ENTIRE list of tracks for UI testing final testAllTracksProvider = FutureProvider>((ref) async { @@ -163,6 +165,10 @@ class HomeScreen extends ConsumerWidget { ], ); + final unreadMessages = ref.watch(unreadMessagesCountProvider); + final unreadNotifications = + ref.watch(unreadNotificationsCountProvider).asData?.value ?? 0; + return Scaffold( key: const Key('home_scaffold'), appBar: AppBar( @@ -223,7 +229,7 @@ class HomeScreen extends ConsumerWidget { /// Navigates user to inbox/messages screen. IconButton( key: const Key('home_inbox_icon_button'), - icon: const Icon(Icons.mail_outline), + icon: _badgeIcon(Icons.mail_outline, hasUnread: unreadMessages > 0), onPressed: () { context.push('/home/inbox'); }, @@ -232,7 +238,10 @@ class HomeScreen extends ConsumerWidget { /// Navigates user to notifications screen. IconButton( key: const Key('home_notifications_icon_button'), - icon: const Icon(Icons.notifications_none), + icon: _badgeIcon( + Icons.notifications_none, + hasUnread: unreadNotifications > 0, + ), onPressed: () { context.push('/home/notifications'); }, @@ -293,6 +302,27 @@ class HomeScreen extends ConsumerWidget { ), ); } + + Widget _badgeIcon(IconData icon, {required bool hasUnread}) { + return Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon), + if (hasUnread) + Positioned( + top: -2, + right: -2, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppTheme.primaryBrand, + shape: BoxShape.circle, + ), + child: SizedBox(width: 8, height: 8), + ), + ), + ], + ); + } } /// Helper to detect if a [DioException] is related to connectivity. diff --git a/lib/features/messaging/data/datasources/data_sources_sockets.dart b/lib/features/messaging/data/datasources/data_sources_sockets.dart index f2e2f235..fd465680 100644 --- a/lib/features/messaging/data/datasources/data_sources_sockets.dart +++ b/lib/features/messaging/data/datasources/data_sources_sockets.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:socket_io_client/socket_io_client.dart' as io; class DataSourcesSockets { @@ -48,10 +47,7 @@ class DataSourcesSockets { _healthCheckTimer = Timer.periodic(const Duration(seconds: 60), (_) { if (_url == null || _getToken == null) return; if (!(_socket?.connected ?? false)) { - debugPrint('💓 Health check: not connected after 60s — rebuilding'); _buildAndConnect(); - } else { - debugPrint('💓 Health check: connected — skipping rebuild'); } }); } @@ -81,32 +77,26 @@ class DataSourcesSockets { _socket!.io.on('reconnect_attempt', (_) { _socket!.auth = {'token': 'Bearer ${_getToken!()}'}; - debugPrint('🔄 reconnect_attempt: auth token refreshed'); }); _socket!.io.on('reconnect_failed', (_) { - debugPrint('⚠️ reconnect_failed — rebuilding socket in 5s'); _retryTimer?.cancel(); _retryTimer = Timer(const Duration(seconds: 5), () { if (_url != null && _getToken != null) { - debugPrint('🔁 Rebuilding socket with fresh instance'); _buildAndConnect(); } }); }); _socket!.onConnect((_) { - debugPrint('✅ Socket connected | id=${_socket?.id}'); _retryTimer?.cancel(); // Subscribe to the user's notification room on every (re)connect. // The backend auto-joins via registerNotificationHandlers, but emitting // this is a defensive fallback for cases where that handler fires after // the connect event. _socket!.emit('notification:subscribe'); - debugPrint('📬 notification:subscribe emitted'); // Re-join the conversation room that was active before the rebuild. if (_currentConversationId != null) { - debugPrint('🔁 onConnect: rejoining room $_currentConversationId'); _socket!.emit('message:join', { 'conversationId': _currentConversationId, }); @@ -114,13 +104,6 @@ class DataSourcesSockets { } }); - _socket!.onDisconnect((reason) { - debugPrint('❌ Socket disconnected: $reason'); - }); - - _socket!.onConnectError((err) => debugPrint('🚨 Connection error: $err')); - _socket!.onError((err) => debugPrint('🚨 Socket error: $err')); - // Re-attach every stored callback to the brand-new socket instance. _reattachListeners(); @@ -137,9 +120,6 @@ class DataSourcesSockets { if (_onMessageReceived != null) { s.off('message:received'); s.on('message:received', (data) { - debugPrint( - '📨 RAW message:received | type=${data.runtimeType} | data=$data', - ); _onMessageReceived!(data as Map); }); } @@ -175,9 +155,6 @@ class DataSourcesSockets { if (_onNotificationCreated != null) { s.off('notification:created'); s.on('notification:created', (data) { - debugPrint( - '🔔 RAW notification:created | type=${data.runtimeType} | data=$data', - ); _onNotificationCreated!(data as Map); }); } @@ -185,30 +162,17 @@ class DataSourcesSockets { if (_onNotificationRead != null) { s.off('notification:read'); s.on('notification:read', (data) { - debugPrint( - '🔕 RAW notification:read | type=${data.runtimeType} | data=$data', - ); _onNotificationRead!(data as Map); }); } - - debugPrint('🔁 _reattachListeners: listeners re-bound to socket=${s.id}'); } // ── Room management ─────────────────────────────────────────────────────── void joinConversation(String conversationId) { _currentConversationId = conversationId; - debugPrint( - '🚪 joinConversation: $conversationId | connected=${_socket?.connected}', - ); if (_socket?.connected == true) { _socket!.emit('message:join', {'conversationId': conversationId}); - debugPrint('📤 message:join emitted for $conversationId'); - } else { - debugPrint( - '⚠️ joinConversation: socket not connected — onConnect will join', - ); } } @@ -220,7 +184,6 @@ class DataSourcesSockets { // ── Emit helpers ────────────────────────────────────────────────────────── void sendMessage(String conversationId, Map message) { - debugPrint('📤 message:send | convId=$conversationId | msg=$message'); _socket?.emit('message:send', { 'conversationId': conversationId, 'message': message, @@ -255,14 +218,8 @@ class DataSourcesSockets { _onMessageReceived = callback; _socket?.off('message:received'); _socket?.on('message:received', (data) { - debugPrint( - '📨 RAW message:received | type=${data.runtimeType} | data=$data', - ); callback(data as Map); }); - debugPrint( - '👂 onMessageReceived listener registered | socket=${_socket?.id}', - ); } void onMessageReadUpdated(Function(Map) callback) { @@ -301,14 +258,8 @@ class DataSourcesSockets { _onNotificationCreated = callback; _socket?.off('notification:created'); _socket?.on('notification:created', (data) { - debugPrint( - '🔔 RAW notification:created | type=${data.runtimeType} | data=$data', - ); callback(data as Map); }); - debugPrint( - '👂 onNotificationCreated listener registered | socket=${_socket?.id}', - ); } void onNotificationRead(Function(Map) callback) { @@ -339,7 +290,6 @@ class DataSourcesSockets { if (_socket == null) return; _socket!.auth = {'token': 'Bearer $token'}; if (!(_socket!.connected)) { - debugPrint('🔄 reconnectWithToken: connecting with fresh token'); if (_url != null && _getToken != null) { _buildAndConnect(); } else { @@ -351,7 +301,6 @@ class DataSourcesSockets { void forceReconnectIfNeeded() { if (_socket == null || _url == null) return; if (!(_socket!.connected)) { - debugPrint('📲 App resumed — rebuilding socket'); _buildAndConnect(); } } diff --git a/lib/features/messaging/data/datasources/datasource_implement.dart b/lib/features/messaging/data/datasources/datasource_implement.dart index e9aeddcb..e67326fd 100644 --- a/lib/features/messaging/data/datasources/datasource_implement.dart +++ b/lib/features/messaging/data/datasources/datasource_implement.dart @@ -283,8 +283,6 @@ class DatasourceImplement implements DatasourceInterface { if (embedType == 'track') { final response = await dio.get(ApiEndPoints.getMyLikedTracks()); final List data = response.data['data']['items']; - // if (data.isNotEmpty) - // print('🎵 liked-track item keys: ${(data.first as Map).keys.toList()}'); return data .map( (e) => SharedEmbedModel( diff --git a/lib/features/messaging/data/models/sent_message_request_model.dart b/lib/features/messaging/data/models/sent_message_request_model.dart index ad720f0f..9377cdc1 100644 --- a/lib/features/messaging/data/models/sent_message_request_model.dart +++ b/lib/features/messaging/data/models/sent_message_request_model.dart @@ -26,7 +26,6 @@ class SentMessageRequestModel { 'id': embedId, }; } - //print('📤 sendMessage JSON: $data'); return data; } } diff --git a/lib/features/messaging/presentation/pages/chat_screen.dart b/lib/features/messaging/presentation/pages/chat_screen.dart index 0d9e88e0..8260a002 100644 --- a/lib/features/messaging/presentation/pages/chat_screen.dart +++ b/lib/features/messaging/presentation/pages/chat_screen.dart @@ -188,8 +188,6 @@ class _ChatScreenState extends ConsumerState { } }); _socket.onMessageReceived((data) { - debugPrint('🔥 onMessageReceived fired: $data'); - if (mounted) { final message = MessageModel.fromJson( data['message'] as Map, diff --git a/lib/features/messaging/presentation/providers/get_liked_embed_provider.dart b/lib/features/messaging/presentation/providers/get_liked_embed_provider.dart index dec735c2..7d2f6350 100644 --- a/lib/features/messaging/presentation/providers/get_liked_embed_provider.dart +++ b/lib/features/messaging/presentation/providers/get_liked_embed_provider.dart @@ -11,11 +11,10 @@ import 'package:rythmify/features/messaging/presentation/providers/repository_pr /// use the same API endpoint. /// Returns a [SharedEmbed] with name, artist, and thumbnail details. -final getLikedEmbedsProvider = FutureProvider.family, String>( - (ref, embedType) async { - final uCase = GetLikedEmbedsUseCase(repo: ref.read(repositoryprovider)); - final String userId = ref.watch(currentUserIdProvider); - final msg = await uCase(userId, embedType); - return msg; - }, -); +final getLikedEmbedsProvider = FutureProvider.autoDispose + .family, String>((ref, embedType) async { + final uCase = GetLikedEmbedsUseCase(repo: ref.read(repositoryprovider)); + final String userId = ref.watch(currentUserIdProvider); + final msg = await uCase(userId, embedType); + return msg; + }); diff --git a/lib/features/messaging/presentation/providers/socket_provider.dart b/lib/features/messaging/presentation/providers/socket_provider.dart index a7aa6640..97b279f8 100644 --- a/lib/features/messaging/presentation/providers/socket_provider.dart +++ b/lib/features/messaging/presentation/providers/socket_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; @@ -12,8 +11,6 @@ final socketProvider = Provider((ref) { authProvider.select((s) => s is AuthAuthenticated ? s.user.id : null), ); - debugPrint('🔌 socketProvider rebuilt | userId=$userId'); - final socket = DataSourcesSockets(); if (userId != null) { diff --git a/lib/features/messaging/presentation/providers/unread_messages_count_provider.dart b/lib/features/messaging/presentation/providers/unread_messages_count_provider.dart new file mode 100644 index 00000000..25277f49 --- /dev/null +++ b/lib/features/messaging/presentation/providers/unread_messages_count_provider.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rythmify/features/messaging/presentation/providers/conversations_provider.dart'; + +final unreadMessagesCountProvider = Provider((ref) { + return ref + .watch(conversationProvider) + .maybeWhen( + data: (convs) => convs.fold(0, (sum, c) => sum + c.unReadCount), + orElse: () => 0, + ); +}); diff --git a/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart b/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart index 2d829ef3..c7999fa8 100644 --- a/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart +++ b/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rythmify/core/presentation/pages/report_page.dart'; import 'package:rythmify/features/messaging/presentation/providers/block_user_provider.dart'; import 'package:rythmify/features/messaging/presentation/providers/is_blocked_provider.dart'; import 'package:rythmify/features/messaging/presentation/widgets/confirm_block_widget.dart'; @@ -89,8 +90,14 @@ class PopUpMenuWidget extends ConsumerWidget { 'Report user', style: TextStyle(color: Colors.white), ), - onTap: () async { + onTap: () { Navigator.pop(context); + Navigator.of(parentContext, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => + ReportPage(reportedContentId: participantId), + ), + ); }, ), ], diff --git a/lib/features/notifications/data/datasources/push_notification_service.dart b/lib/features/notifications/data/datasources/push_notification_service.dart index 716ad710..22f0a096 100644 --- a/lib/features/notifications/data/datasources/push_notification_service.dart +++ b/lib/features/notifications/data/datasources/push_notification_service.dart @@ -1,5 +1,4 @@ import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:go_router/go_router.dart'; import 'package:rythmify/core/network/api_client.dart'; @@ -19,7 +18,6 @@ class PushNotificationService { PushNotificationService(this._route); Future initialize() async { - debugPrint('🔔 [PUSH] initialize() started'); FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); await _requestPermission(); @@ -29,16 +27,10 @@ class PushNotificationService { _handleForegroundmessages(); _handleMessageOpenApp(); await _handleIntitialMessage(); - debugPrint('✅ [PUSH] initialize() complete'); } Future _requestPermission() async { - final settings = await _fcm.requestPermission( - alert: true, - badge: true, - sound: true, - ); - debugPrint('🔐 [PUSH] permission status: ${settings.authorizationStatus}'); + await _fcm.requestPermission(alert: true, badge: true, sound: true); } Future _initLocalNotifications() async { @@ -65,21 +57,16 @@ class PushNotificationService { Future _registerToken() async { final token = await _fcm.getToken(); - debugPrint('📱 [PUSH] FCM token: $token'); if (token != null) _postToken(token); } Future _postToken(String token) async { try { - debugPrint('📤 [PUSH] registering token with backend...'); await apiClient.dio.post( '/notifications/push/register', data: {'token': token, 'platform': 'android'}, ); - debugPrint('✅ [PUSH] token registered successfully'); - } catch (e) { - debugPrint('❌ [PUSH] token registration failed: $e'); - } + } catch (_) {} } Future unregisterToken() async { @@ -96,9 +83,6 @@ class PushNotificationService { void _handleForegroundmessages() { FirebaseMessaging.onMessage.listen((message) { - debugPrint( - '📩 [PUSH] foreground message received: title="${message.notification?.title}" data=${message.data}', - ); final notification = message.notification; if (notification == null) return; @@ -122,9 +106,6 @@ class PushNotificationService { void _handleMessageOpenApp() { FirebaseMessaging.onMessageOpenedApp.listen((message) { - debugPrint( - '👆 [PUSH] notification tapped (app was background): data=${message.data}', - ); _navigateFromData(message.data); }); } @@ -132,9 +113,6 @@ class PushNotificationService { Future _handleIntitialMessage() async { final message = await _fcm.getInitialMessage(); if (message != null) { - debugPrint( - '🚀 [PUSH] app launched from notification: data=${message.data}', - ); _navigateFromData(message.data); } } @@ -171,10 +149,7 @@ class PushNotificationService { } void _listenForTokenRefresh() { - _fcm.onTokenRefresh.listen((token) { - debugPrint('🔄 [PUSH] token refreshed, re-registering...'); - _postToken(token); - }); + _fcm.onTokenRefresh.listen(_postToken); } void _navigateByType({ @@ -182,9 +157,6 @@ class PushNotificationService { String? resourceType, String? resourceId, }) { - debugPrint( - '🧭 [PUSH] navigating — type=$type resourceType=$resourceType resourceId=$resourceId', - ); final hasResource = resourceId != null && resourceId.isNotEmpty; switch (type) { diff --git a/lib/features/notifications/presentation/providers/notifications_provider.dart b/lib/features/notifications/presentation/providers/notifications_provider.dart index 579851b7..7def79a1 100644 --- a/lib/features/notifications/presentation/providers/notifications_provider.dart +++ b/lib/features/notifications/presentation/providers/notifications_provider.dart @@ -135,13 +135,6 @@ class NotificationsNotifier extends StateNotifier { _followState.fetchFollowState(n.actorId); } - // if (unread.isNotEmpty) { - // state = state.copyWith(unreadCount: 0); - // for (final n in unread) { - // _markRead(n.id).catchError((_) {}); - // } - // } - for (final n in unread) { _markRead(n.id).catchError((_) {}); } diff --git a/lib/features/notifications/presentation/providers/unread_count_provider.dart b/lib/features/notifications/presentation/providers/unread_count_provider.dart new file mode 100644 index 00000000..63e0f80c --- /dev/null +++ b/lib/features/notifications/presentation/providers/unread_count_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rythmify/features/notifications/domain/usecases/get_unread_count_usecase.dart'; +import 'repo_provider.dart'; + +final unreadNotificationsCountProvider = FutureProvider((ref) { + final repo = ref.watch(repositoryprovider); + return GetUnreadCountUsecase(repo).call(); +}); diff --git a/lib/features/notifications/presentation/widgets/notification_tile.dart b/lib/features/notifications/presentation/widgets/notification_tile.dart index 5e6761db..45b31825 100644 --- a/lib/features/notifications/presentation/widgets/notification_tile.dart +++ b/lib/features/notifications/presentation/widgets/notification_tile.dart @@ -221,12 +221,18 @@ class NotificationTile extends StatelessWidget { } String _timeAgo(DateTime dt) { - final diff = DateTime.now().difference(dt); + final now = DateTime.now(); + final diff = now.difference(dt); if (diff.inMinutes < 1) return 'now'; if (diff.inMinutes < 60) return '${diff.inMinutes}m'; - if (diff.inHours < 24) return '${diff.inHours}h'; - if (diff.inDays < 365) return '${diff.inDays}d'; - return '${(diff.inDays / 365).floor()}y'; + final days = DateTime( + now.year, + now.month, + now.day, + ).difference(DateTime(dt.year, dt.month, dt.day)).inDays; + if (days == 0) return '${diff.inHours}h'; + if (days < 365) return '${days}d'; + return '${(days / 365).floor()}y'; } } diff --git a/lib/features/settings/presentation/pages/add_widget_screen.dart b/lib/features/settings/presentation/pages/add_widget_screen.dart index c73ccb49..efb945cd 100644 --- a/lib/features/settings/presentation/pages/add_widget_screen.dart +++ b/lib/features/settings/presentation/pages/add_widget_screen.dart @@ -27,6 +27,19 @@ class AddWidgetScreen extends StatelessWidget { key: Key('add_you_likes_widget'), imgPath: 'assets/images/likes_widget.jpeg', height: 250, + onTap: () {}, + // onTap: () async { + // final added = await WidgetPinService.requestPinLikes(); + // if (!added && context.mounted) { + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: Text( + // 'Widget pinning is not supported on this launcher', + // ), + // ), + // ); + // } + // }, ), const SizedBox(height: 15), _buildCard( @@ -34,6 +47,19 @@ class AddWidgetScreen extends StatelessWidget { key: Key('add_player_widget'), imgPath: 'assets/images/player_widget.jpeg', height: 210, + onTap: () {}, + // onTap: () async { + // final added = await WidgetPinService.requestPin(); + // if (!added && context.mounted) { + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: Text( + // 'Widget pinning is not supported on this launcher', + // ), + // ), + // ); + // } + // }, ), ], ), @@ -45,6 +71,7 @@ class AddWidgetScreen extends StatelessWidget { required Key key, required String imgPath, required double height, + VoidCallback? onTap, }) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 0), @@ -54,7 +81,7 @@ class AddWidgetScreen extends StatelessWidget { ).copyWith(splashFactory: InkRipple.splashFactory), child: InkWell( key: key, - onTap: () {}, + onTap: onTap, borderRadius: BorderRadius.circular(16), splashColor: Colors.white.withValues(alpha: 0.2), highlightColor: Colors.white.withValues(alpha: 0.1), diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index 127baee6..18e402e4 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -1,8 +1,13 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; import 'package:rythmify/features/settings/presentation/widgets/settings_options_tile_widget.dart'; import 'package:rythmify/features/settings/presentation/widgets/sign_out_button_widget.dart'; +import 'package:rythmify/features/track_upload/presentation/providers/upload_track_provider.dart'; /// The main settings screen displaying all available settings categories as navigable tiles. /// Organized into three groups separated by spacing: @@ -40,7 +45,46 @@ class SettingsScreen extends ConsumerWidget { //3- Upload key: Key('option_Upload'), title: 'Upload', - onTap: () {}, + onTap: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.audio, + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) return; + final picked = result.files.first; + if (picked.path == null) return; + + Duration duration = Duration.zero; + try { + final player = AudioPlayer(); + final detected = await player.setFilePath(picked.path!); + duration = detected ?? Duration.zero; + await player.dispose(); + } catch (_) {} + + // ── Fix: read display name from auth provider ── + final authState = ref.read(authProvider); + final displayName = authState is AuthAuthenticated + ? authState.user.displayName + : 'Your Name'; + + ref + .read(uploadFormProvider.notifier) + .initDraft( + artistId: 'dev_user_001', + artistName: displayName, + localAudioPath: picked.path!, + duration: duration, + fileName: picked.name, + ); + + if (context.mounted) { + context.push('/upload-track'); + // Start audio upload right after navigating + ref.read(uploadFormProvider.notifier).startAudioUpload(); + } + }, ), SettingsOptionsTileWidget( //4- Basic settings diff --git a/lib/main.dart b/lib/main.dart index 640b90f3..1e206395 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,8 @@ import 'core/theme/app_theme.dart'; import 'package:audio_service/audio_service.dart'; import 'features/player/data/datasources/audio_handler.dart'; import 'features/player/presentation/providers/player_dependency_providers.dart'; +import 'core/widget/widget_sync_provider.dart'; +import 'core/widget/likes_widget_sync_provider.dart'; late AudioHandler globalAudioHandler; @@ -98,6 +100,9 @@ class RythmifyApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Keep widget sync notifiers alive for the entire app lifetime + ref.watch(widgetSyncProvider); + ref.watch(likesWidgetSyncProvider); final router = ref.watch(routerProvider); return MaterialApp.router( diff --git a/pubspec.lock b/pubspec.lock index 375d08ec..c83ad051 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -914,6 +914,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: "7430f7549d42cef2e729bd3c779de748b93f1eb78b1abfe6bca8fffd1cfce3e9" + url: "https://pub.dev" + source: hosted + version: "0.7.0+1" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 17341cd8..94c2103b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: dynamic_app_icon_flutter_plus: ^0.0.1 flutter_web_auth_2: ^4.0.0 # handles the browser + deep link callback crypto: ^3.0.3 # for PKCE code challenge generation + home_widget: ^0.7.0 # home screen widget bridge (Flutter ↔ Android AppWidget) diff --git a/test/features/messaging/data/datasources/api_endpoints_test.dart b/test/features/messaging/data/datasources/api_endpoints_test.dart new file mode 100644 index 00000000..dd92f51f --- /dev/null +++ b/test/features/messaging/data/datasources/api_endpoints_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/datasources/api_endpoints.dart'; + +void main() { + group('ApiEndPoints', () { + test('getConversations is correct', () { + expect(ApiEndPoints.getConversations, '/messages/conversations'); + }); + + test('getConversation returns correct path', () { + expect(ApiEndPoints.getConversation('c1'), '/messages/conversations/c1'); + }); + + test('getMessages returns path with offset and limit', () { + expect( + ApiEndPoints.getMessages('c1', offset: 5, limit: 20), + '/messages/conversations/c1?offset=5&limit=20', + ); + }); + + test('getMessages uses defaults offset=0 limit=100', () { + expect( + ApiEndPoints.getMessages('c1'), + '/messages/conversations/c1?offset=0&limit=100', + ); + }); + + test('sendMessage returns correct path', () { + expect( + ApiEndPoints.sendMessage('c1'), + '/messages/conversations/c1/messages', + ); + }); + + test('newConversation is correct', () { + expect(ApiEndPoints.newConversation, '/messages/new'); + }); + + test('ensureConversation is correct', () { + expect(ApiEndPoints.ensureConversation, '/messages/conversations/ensure'); + }); + + test('getUnreadCount is correct', () { + expect(ApiEndPoints.getUnreadCount, '/messages/unread-count'); + }); + + test('blockUser returns correct path', () { + expect(ApiEndPoints.blockUser('u1'), '/users/u1/block'); + }); + + test('unBlockUser returns correct path', () { + expect(ApiEndPoints.unBlockUser('u1'), '/users/u1/block'); + }); + + test('markMessagesAsRead returns correct path', () { + expect( + ApiEndPoints.markMessagesAsRead('c1', 'm1'), + '/messages/conversations/c1/messages/m1/read', + ); + }); + + test('getFollowings returns correct path', () { + expect(ApiEndPoints.getFollowings(), '/users/me/following'); + }); + + test('getSearchedUsers returns correct path', () { + expect(ApiEndPoints.getSearchedUsers('ali'), '/search?q=ali&type=users'); + }); + + test('isBlocked returns correct path', () { + expect(ApiEndPoints.isBlocked('u1'), '/users/u1/follow-status'); + }); + + test('getTrackDetails returns correct path', () { + expect(ApiEndPoints.getTrackDetails('t1'), '/tracks/t1'); + }); + + test('getPlaylistDetails returns correct path', () { + expect(ApiEndPoints.getPlaylistDetails('p1'), '/playlists/p1'); + }); + + test('getMyLikedTracks returns correct path', () { + expect(ApiEndPoints.getMyLikedTracks(), '/me/liked-tracks'); + }); + + test('getMyLikedPlaylists returns correct path', () { + expect(ApiEndPoints.getMyLikedPlaylists(), '/me/liked-playlists'); + }); + + test('getMyLikedAlbums returns correct path', () { + expect(ApiEndPoints.getMyLikedAlbums(), '/me/liked-albums'); + }); + }); +} diff --git a/test/features/messaging/data/datasources/data_sources_sockets_test.dart b/test/features/messaging/data/datasources/data_sources_sockets_test.dart new file mode 100644 index 00000000..4ba2647d --- /dev/null +++ b/test/features/messaging/data/datasources/data_sources_sockets_test.dart @@ -0,0 +1,583 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/datasources/data_sources_sockets.dart'; + +// ── Minimal engine.io v4 + socket.io v4 WebSocket server ──────────────────── +// +// Implements just enough of the protocol to let the Dart socket.io client +// connect, exchange packets, and disconnect cleanly: +// +// Engine.io OPEN: 0{...} +// Engine.io PING: 2 → client replies 3 +// Socket.io CONNECT: 40 (from client) → server replies 40 +// Socket.io EVENT: 42["event",data] +// Socket.io DISCONNECT: 41 +// ──────────────────────────────────────────────────────────────────────────── + +class _MockSocketIoServer { + HttpServer? _http; + WebSocket? _ws; + final _connected = Completer(); + + int get port => _http!.port; + + /// Resolves once the client has completed the socket.io handshake. + Future get onConnected => _connected.future; + + Future start() async { + _http = await HttpServer.bind('localhost', 0); + _http!.listen(_handleRequest); + } + + Future _handleRequest(HttpRequest req) async { + if (!WebSocketTransformer.isUpgradeRequest(req)) { + req.response.statusCode = 400; + await req.response.close(); + return; + } + _ws = await WebSocketTransformer.upgrade(req); + + // engine.io OPEN — high ping interval so tests never have to deal with pings + _ws!.add( + '0{"sid":"test-sid","upgrades":[],' + '"pingInterval":300000,"pingTimeout":200000,"maxPayload":1000000}', + ); + + _ws!.listen( + (raw) { + final data = raw.toString(); + if (data == '2') { + _ws!.add('3'); // PONG + } else if (data.startsWith('40')) { + // socket.io CONNECT from client → ack and signal test + _ws!.add('40{"sid":"test-socket-sid"}'); + Future.delayed(const Duration(milliseconds: 20), () { + if (!_connected.isCompleted) _connected.complete(); + }); + } + }, + onDone: () { + /* client closed */ + }, + ); + } + + /// Send a socket.io event to the client. + void sendEvent(String event, dynamic data) { + _ws?.add('42${jsonEncode([event, data])}'); + } + + /// Close the WebSocket (triggers onDisconnect on the client side). + Future closeWs() async { + await _ws?.close(); + } + + Future stop() async { + await _ws?.close(); + await _http?.close(force: true); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +void main() { + // ── helpers ──────────────────────────────────────────────────────────────── + + /// Waits until [condition] is true, polling every [interval]. + /// Fails (throws) after [timeout] if condition is never met. + Future waitFor( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), + Duration interval = const Duration(milliseconds: 20), + }) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('waitFor condition not met within $timeout'); + } + await Future.delayed(interval); + } + } + + // ── without connect ──────────────────────────────────────────────────────── + + group('without connect (null socket)', () { + late DataSourcesSockets ds; + + setUp(() => ds = DataSourcesSockets()); + + tearDown(() => ds.disconnect()); + + test('setOnReconnectedToRoom stores callback', () { + var called = false; + ds.setOnReconnectedToRoom(() => called = true); + expect(called, isFalse); // stored, not called yet + }); + + test('onMessageReceived stores callback without error', () { + expect(() => ds.onMessageReceived((_) {}), returnsNormally); + }); + + test('onMessageReadUpdated stores callback without error', () { + expect(() => ds.onMessageReadUpdated((_) {}), returnsNormally); + }); + + test('onTyping stores callback without error', () { + expect(() => ds.onTyping((_) {}), returnsNormally); + }); + + test('onStopTyping stores callback without error', () { + expect(() => ds.onStopTyping((_) {}), returnsNormally); + }); + + test('onUserBlocked stores callback without error', () { + expect(() => ds.onUserBlocked((_) {}), returnsNormally); + }); + + test('onNotificationCreated stores callback without error', () { + expect(() => ds.onNotificationCreated((_) {}), returnsNormally); + }); + + test('onNotificationRead stores callback without error', () { + expect(() => ds.onNotificationRead((_) {}), returnsNormally); + }); + + test('clearConversationListeners runs without error', () { + ds.onMessageReceived((_) {}); + ds.onMessageReadUpdated((_) {}); + ds.onUserBlocked((_) {}); + ds.setOnReconnectedToRoom(() {}); + expect(() => ds.clearConversationListeners(), returnsNormally); + }); + + test('joinConversation stores conversationId without error', () { + expect(() => ds.joinConversation('c1'), returnsNormally); + }); + + test('leaveConversation runs without error', () { + expect(() => ds.leaveConversation('c1'), returnsNormally); + }); + + test('sendMessage runs without error when not connected', () { + expect(() => ds.sendMessage('c1', {'body': 'hello'}), returnsNormally); + }); + + test('markRead runs without error when not connected', () { + expect(() => ds.markRead('c1', 'm1', true, 0), returnsNormally); + }); + + test('sendTyping runs without error when not connected', () { + expect(() => ds.sendTyping('c1'), returnsNormally); + }); + + test('sendStopTyping runs without error when not connected', () { + expect(() => ds.sendStopTyping('c1'), returnsNormally); + }); + + test('reconnectWithToken returns immediately when socket is null', () { + expect(() => ds.reconnectWithToken('new-token'), returnsNormally); + }); + + test('forceReconnectIfNeeded returns immediately when socket is null', () { + expect(() => ds.forceReconnectIfNeeded(), returnsNormally); + }); + + test('disconnect runs without error when never connected', () { + expect(() => ds.disconnect(), returnsNormally); + }); + }); + + // ── with real mock server ───────────────────────────────────────────────── + + group('with mock socket.io server', () { + late _MockSocketIoServer server; + late DataSourcesSockets ds; + + setUp(() async { + server = _MockSocketIoServer(); + await server.start(); + ds = DataSourcesSockets(); + }); + + tearDown(() async { + ds.disconnect(); + await server.stop(); + // Let any pending async I/O settle + await Future.delayed(const Duration(milliseconds: 50)); + }); + + // ── basic connect / disconnect ────────────────────────────────────────── + + test('connect establishes socket.io connection', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + // If we reach here, the socket connected successfully + }); + + test('onConnect handler fires after connection', () async { + var connectFired = false; + + // Register a test-callback that detects the connect event + // We verify it indirectly: the onConnect handler emits 'notification:subscribe' + // and calls _retryTimer?.cancel(). Connecting without error proves it ran. + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + connectFired = true; + + expect(connectFired, isTrue); + }); + + test( + 'onConnect handler joins room when conversationId is pre-set', + () async { + ds.setOnReconnectedToRoom(() {}); + ds.joinConversation('c1'); // sets _currentConversationId before connect + ds.connect('ws://localhost:${server.port}', () => 'token'); + // onConnect fires and emits 'message:join' + calls _onReconnectedToRoom + await server.onConnected.timeout(const Duration(seconds: 5)); + // No assertion needed — we're verifying no error is thrown + }, + ); + + test('disconnect triggers onDisconnect handler', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + // disconnect() calls socket.disconnect() which emits 'disconnect' event + expect(() => ds.disconnect(), returnsNormally); + }); + + // ── emit helpers after connect ────────────────────────────────────────── + + test('joinConversation emits message:join when connected', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + expect(() => ds.joinConversation('conv-1'), returnsNormally); + }); + + test('leaveConversation emits message:leave when connected', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + expect(() => ds.leaveConversation('conv-1'), returnsNormally); + }); + + test('sendMessage emits when connected', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + expect( + () => ds.sendMessage('conv-1', {'body': 'hello'}), + returnsNormally, + ); + }); + + test('markRead emits when connected', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + expect(() => ds.markRead('conv-1', 'm1', true, 0), returnsNormally); + }); + + test('sendTyping emits when connected', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + expect(() => ds.sendTyping('conv-1'), returnsNormally); + }); + + test('sendStopTyping emits when connected', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + expect(() => ds.sendStopTyping('conv-1'), returnsNormally); + }); + + // ── reconnect / force reconnect after connect ─────────────────────────── + + test( + 'reconnectWithToken updates auth and reconnects when not connected', + () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + // After connected, calling reconnectWithToken with same token should be + // a no-op since socket IS connected. Disconnect first then call. + // We test the "not connected" branch by disconnecting: + ds.disconnect(); + await Future.delayed(const Duration(milliseconds: 30)); + // Now socket is null — reconnectWithToken returns immediately + expect(() => ds.reconnectWithToken('new-token'), returnsNormally); + }, + ); + + test('forceReconnectIfNeeded rebuilds socket when not connected', () async { + final server2 = _MockSocketIoServer(); + await server2.start(); + final ds2 = DataSourcesSockets(); + addTearDown(() async { + ds2.disconnect(); + await server2.stop(); + }); + + ds2.connect('ws://localhost:${server2.port}', () => 'token'); + await server2.onConnected.timeout(const Duration(seconds: 5)); + + // Close the server-side WebSocket to force a disconnect + await server2.closeWs(); + await Future.delayed(const Duration(milliseconds: 100)); + + // Now call forceReconnectIfNeeded — socket may not be connected + expect(() => ds2.forceReconnectIfNeeded(), returnsNormally); + }); + + // ── listener callbacks fired by server events ─────────────────────────── + + test( + 'onMessageReceived callback fires when server sends message:received', + () async { + final completer = Completer>(); + + // Register callback BEFORE connect so _reattachListeners wires it up + ds.onMessageReceived((data) { + if (!completer.isCompleted) completer.complete(data); + }); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + server.sendEvent('message:received', {'body': 'hello', 'id': 'm1'}); + final received = await completer.future.timeout( + const Duration(seconds: 3), + ); + expect(received['body'], 'hello'); + }, + ); + + test( + 'onMessageReceived callback re-registered after calling method again', + () async { + final completer = Completer>(); + + ds.onMessageReceived((_) {}); // first registration (pre-connect) + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + // Re-register after connect — exercises the public method's on() path + ds.onMessageReceived((data) { + if (!completer.isCompleted) completer.complete(data); + }); + + server.sendEvent('message:received', {'body': 'world'}); + final received = await completer.future.timeout( + const Duration(seconds: 3), + ); + expect(received['body'], 'world'); + }, + ); + + test( + 'onMessageReadUpdated callback fires when server sends message:read_updated', + () async { + final completer = Completer>(); + + ds.onMessageReadUpdated((data) { + if (!completer.isCompleted) completer.complete(data); + }); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + server.sendEvent('message:read_updated', {'messageId': 'm1'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['messageId'], 'm1'); + }, + ); + + test('onMessageReadUpdated re-registered after connect', () async { + final completer = Completer>(); + ds.onMessageReadUpdated((_) {}); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + ds.onMessageReadUpdated((data) { + if (!completer.isCompleted) completer.complete(data); + }); + server.sendEvent('message:read_updated', {'messageId': 'm2'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['messageId'], 'm2'); + }); + + test('onTyping callback fires when server sends message:typing', () async { + final completer = Completer>(); + + ds.onTyping((data) { + if (!completer.isCompleted) completer.complete(data); + }); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + server.sendEvent('message:typing', {'userId': 'u1'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['userId'], 'u1'); + }); + + test('onTyping re-registered after connect', () async { + final completer = Completer>(); + ds.onTyping((_) {}); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + ds.onTyping((data) { + if (!completer.isCompleted) completer.complete(data); + }); + server.sendEvent('message:typing', {'userId': 'u2'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['userId'], 'u2'); + }); + + test( + 'onStopTyping callback fires when server sends message:stop_typing', + () async { + final completer = Completer>(); + + ds.onStopTyping((data) { + if (!completer.isCompleted) completer.complete(data); + }); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + server.sendEvent('message:stop_typing', {'userId': 'u1'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['userId'], 'u1'); + }, + ); + + test('onStopTyping re-registered after connect', () async { + final completer = Completer>(); + ds.onStopTyping((_) {}); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + ds.onStopTyping((data) { + if (!completer.isCompleted) completer.complete(data); + }); + server.sendEvent('message:stop_typing', {'userId': 'u3'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['userId'], 'u3'); + }); + + test( + 'onUserBlocked callback fires when server sends user:blocked', + () async { + final completer = Completer>(); + + ds.onUserBlocked((data) { + if (!completer.isCompleted) completer.complete(data); + }); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + server.sendEvent('user:blocked', {'userId': 'u5'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['userId'], 'u5'); + }, + ); + + test('onUserBlocked re-registered after connect', () async { + final completer = Completer>(); + ds.onUserBlocked((_) {}); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + ds.onUserBlocked((data) { + if (!completer.isCompleted) completer.complete(data); + }); + server.sendEvent('user:blocked', {'userId': 'u6'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['userId'], 'u6'); + }); + + test( + 'onNotificationCreated callback fires on notification:created', + () async { + final completer = Completer>(); + + ds.onNotificationCreated((data) { + if (!completer.isCompleted) completer.complete(data); + }); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + server.sendEvent('notification:created', {'notifId': 'n1'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['notifId'], 'n1'); + }, + ); + + test('onNotificationCreated re-registered after connect', () async { + final completer = Completer>(); + ds.onNotificationCreated((_) {}); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + ds.onNotificationCreated((data) { + if (!completer.isCompleted) completer.complete(data); + }); + server.sendEvent('notification:created', {'notifId': 'n2'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['notifId'], 'n2'); + }); + + test('onNotificationRead callback fires on notification:read', () async { + final completer = Completer>(); + + ds.onNotificationRead((data) { + if (!completer.isCompleted) completer.complete(data); + }); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + server.sendEvent('notification:read', {'notifId': 'n1'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['notifId'], 'n1'); + }); + + test('onNotificationRead re-registered after connect', () async { + final completer = Completer>(); + ds.onNotificationRead((_) {}); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + ds.onNotificationRead((data) { + if (!completer.isCompleted) completer.complete(data); + }); + server.sendEvent('notification:read', {'notifId': 'n3'}); + final data = await completer.future.timeout(const Duration(seconds: 3)); + expect(data['notifId'], 'n3'); + }); + + // ── clearConversationListeners ────────────────────────────────────────── + + test( + 'clearConversationListeners stops message:received from firing', + () async { + var callCount = 0; + ds.onMessageReceived((_) => callCount++); + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + ds.clearConversationListeners(); + + // Listener was cleared — server event should not invoke the callback + server.sendEvent('message:received', {'body': 'ignored'}); + await Future.delayed(const Duration(milliseconds: 100)); + expect(callCount, 0); + }, + ); + + // ── reconnectWithToken with live socket ───────────────────────────────── + + test('reconnectWithToken with live connected socket is a no-op', () async { + ds.connect('ws://localhost:${server.port}', () => 'token'); + await server.onConnected.timeout(const Duration(seconds: 5)); + + // Socket IS connected → reconnectWithToken should not rebuild + expect(() => ds.reconnectWithToken('token'), returnsNormally); + }); + }); +} diff --git a/test/features/messaging/data/datasources/datasource_implement_test.dart b/test/features/messaging/data/datasources/datasource_implement_test.dart index 478a53aa..be9c2125 100644 --- a/test/features/messaging/data/datasources/datasource_implement_test.dart +++ b/test/features/messaging/data/datasources/datasource_implement_test.dart @@ -640,7 +640,12 @@ void main() { {'id': 'p3', 'name': 'Created One', 'cover_image': null}, ]; - when(() => mockDio.get(any())).thenAnswer((inv) async { + when( + () => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer((inv) async { final url = inv.positionalArguments.first as String; if (url.contains('liked-playlists')) { return _resp({ @@ -666,7 +671,12 @@ void main() { {'id': 'p3', 'cover_image': null}, ]; - when(() => mockDio.get(any())).thenAnswer( + when( + () => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( (_) async => _resp({ 'data': {'items': items}, }), @@ -680,7 +690,12 @@ void main() { }); test('returns empty list when both sources are empty', () async { - when(() => mockDio.get(any())).thenAnswer( + when( + () => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + ), + ).thenAnswer( (_) async => _resp({ 'data': {'items': []}, }), diff --git a/test/features/messaging/data/datasources/mock_datasource_test.dart b/test/features/messaging/data/datasources/mock_datasource_test.dart new file mode 100644 index 00000000..3e03835a --- /dev/null +++ b/test/features/messaging/data/datasources/mock_datasource_test.dart @@ -0,0 +1,531 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/messaging/data/datasources/mock_datasource.dart'; +import 'package:rythmify/features/messaging/data/models/sent_message_request_model.dart'; + +void main() { + late MockDatasourceImplement ds; + + setUp(() { + ds = MockDatasourceImplement(); + }); + + // ── getConversations ────────────────────────────────────────────────────── + + group('getConversations', () { + test('returns two initial conversations', () async { + final convs = await ds.getConversations(); + expect(convs.length, 2); + }); + + test('first conversation has correct data', () async { + final convs = await ds.getConversations(); + expect(convs.first.conversationId, 'c1'); + expect(convs.first.participantId, 'u2'); + expect(convs.first.participantName, 'Ali'); + expect(convs.first.unReadCount, 2); + }); + + test('second conversation is for Mona', () async { + final convs = await ds.getConversations(); + expect(convs[1].participantName, 'Mona'); + expect(convs[1].unReadCount, 0); + }); + + test( + 'returns a defensive copy so mutations do not affect internal state', + () async { + final a = await ds.getConversations(); + a.clear(); + final b = await ds.getConversations(); + expect(b.length, 2); + }, + ); + }); + + // ── getMessages ─────────────────────────────────────────────────────────── + + group('getMessages', () { + test('returns all messages for c1', () async { + final (msgs, total) = await ds.getMessages(conversationId: 'c1'); + expect(msgs.length, 3); + expect(total, 3); + }); + + test('returns all messages for c2', () async { + final (msgs, total) = await ds.getMessages(conversationId: 'c2'); + expect(msgs.length, 1); + expect(total, 1); + }); + + test('returns empty list for unknown conversation', () async { + final (msgs, total) = await ds.getMessages(conversationId: 'unknown'); + expect(msgs, isEmpty); + expect(total, 0); + }); + + test('respects offset parameter', () async { + final (msgs, total) = await ds.getMessages( + conversationId: 'c1', + offset: 2, + ); + expect(msgs.length, 1); + expect(total, 3); + expect(msgs.first.messageId, 'm3'); + }); + + test('offset beyond total returns empty list', () async { + final (msgs, total) = await ds.getMessages( + conversationId: 'c1', + offset: 10, + ); + expect(msgs, isEmpty); + expect(total, 3); + }); + }); + + // ── sendMessage ─────────────────────────────────────────────────────────── + + group('sendMessage', () { + test('creates a message with body', () async { + final req = SentMessageRequestModel(body: 'Hello'); + final msg = await ds.sendMessage( + conversationId: 'c1', + requestContent: req, + ); + expect(msg.body, 'Hello'); + expect(msg.conversationId, 'c1'); + expect(msg.senderId, 'current_user'); + expect(msg.isRead, true); + }); + + test('creates a message with embed', () async { + final req = SentMessageRequestModel( + body: 'Check this', + embedType: 'track', + embedId: 't1', + ); + final msg = await ds.sendMessage( + conversationId: 'c1', + requestContent: req, + ); + expect(msg.embedType, 'track'); + expect(msg.embedId, 't1'); + }); + + test('appends the message so next getMessages returns it', () async { + final req = SentMessageRequestModel(body: 'New msg'); + await ds.sendMessage(conversationId: 'c1', requestContent: req); + final (msgs, total) = await ds.getMessages(conversationId: 'c1'); + expect(total, 4); + expect(msgs.last.body, 'New msg'); + }); + + test('updates conversation lastMessagePreview', () async { + final req = SentMessageRequestModel(body: 'Preview update'); + await ds.sendMessage(conversationId: 'c1', requestContent: req); + final convs = await ds.getConversations(); + final c1 = convs.firstWhere((c) => c.conversationId == 'c1'); + expect(c1.lastMessagePreview, 'Preview update'); + }); + + test( + 'sends message with null body updates preview to empty string', + () async { + final req = SentMessageRequestModel(embedType: 'track', embedId: 't1'); + await ds.sendMessage(conversationId: 'c1', requestContent: req); + final convs = await ds.getConversations(); + final c1 = convs.firstWhere((c) => c.conversationId == 'c1'); + expect(c1.lastMessagePreview, ''); + }, + ); + + test('message sent to unknown conversation is stored', () async { + final req = SentMessageRequestModel(body: 'Test'); + final msg = await ds.sendMessage( + conversationId: 'unknown_conv', + requestContent: req, + ); + expect(msg.conversationId, 'unknown_conv'); + final (msgs, _) = await ds.getMessages(conversationId: 'unknown_conv'); + expect(msgs.length, 1); + }); + }); + + // ── newConversation ─────────────────────────────────────────────────────── + + group('newConversation', () { + test( + 'returns existing conversation when participant already has one', + () async { + final conv = await ds.newConversation(participantId: 'u2'); + expect(conv.conversationId, 'c1'); + }, + ); + + test('sends message to existing conversation when body provided', () async { + await ds.newConversation(participantId: 'u2', body: 'Re-hello'); + final (msgs, _) = await ds.getMessages(conversationId: 'c1'); + expect(msgs.last.body, 'Re-hello'); + }); + + test('sends track embed to existing conversation', () async { + await ds.newConversation( + participantId: 'u2', + body: 'check it', + trackId: 't1', + ); + final (msgs, _) = await ds.getMessages(conversationId: 'c1'); + expect(msgs.last.embedType, 'track'); + expect(msgs.last.embedId, 't1'); + }); + + test('sends playlist embed to existing conversation', () async { + await ds.newConversation( + participantId: 'u2', + body: 'playlist', + playlistId: 'p1', + ); + final (msgs, _) = await ds.getMessages(conversationId: 'c1'); + expect(msgs.last.embedType, 'playlist'); + expect(msgs.last.embedId, 'p1'); + }); + + test( + 'creates new conversation for unknown participant with body', + () async { + final conv = await ds.newConversation( + participantId: 'u7', + body: 'Hi Sara', + ); + expect(conv.participantId, 'u7'); + expect(conv.participantName, 'Sara'); + expect(conv.lastMessagePreview, 'Hi Sara'); + }, + ); + + test( + 'creates new conversation for unknown-user with fallback name', + () async { + final conv = await ds.newConversation( + participantId: 'unknown_user', + body: 'test', + ); + expect(conv.participantName, 'Unknown User'); + }, + ); + + test('creates new conversation without body has empty preview', () async { + final conv = await ds.newConversation(participantId: 'u8'); + expect(conv.lastMessagePreview, ''); + expect(conv.unReadCount, 0); + }); + + test('creates new conversation with track embed only', () async { + final conv = await ds.newConversation( + participantId: 'u8', + body: 'song', + trackId: 'track-x', + ); + expect(conv.participantId, 'u8'); + final (msgs, _) = await ds.getMessages( + conversationId: conv.conversationId, + ); + expect(msgs.first.embedType, 'track'); + expect(msgs.first.embedId, 'track-x'); + }); + + test('creates new conversation with playlist embed only', () async { + final conv = await ds.newConversation( + participantId: 'u6', + body: 'playlist', + playlistId: 'pl-x', + ); + final (msgs, _) = await ds.getMessages( + conversationId: conv.conversationId, + ); + expect(msgs.first.embedType, 'playlist'); + expect(msgs.first.embedId, 'pl-x'); + }); + + test('new conversation without body has empty messages list', () async { + final conv = await ds.newConversation(participantId: 'u5'); + final (msgs, _) = await ds.getMessages( + conversationId: conv.conversationId, + ); + expect(msgs, isEmpty); + }); + + test( + 'returns same conversation on second call for same participant', + () async { + final conv1 = await ds.newConversation( + participantId: 'u4', + body: 'first', + ); + final conv2 = await ds.newConversation( + participantId: 'u4', + body: 'second', + ); + expect(conv1.participantId, conv2.participantId); + }, + ); + }); + + // ── ensureConversation ──────────────────────────────────────────────────── + + group('ensureConversation', () { + test('returns existing conversation for known participant', () async { + final conv = await ds.ensureConversation(participantId: 'u2'); + expect(conv.conversationId, 'c1'); + }); + + test('creates new conversation for participant without one', () async { + final conv = await ds.ensureConversation(participantId: 'u7'); + expect(conv.participantId, 'u7'); + expect(conv.participantName, 'Sara'); + }); + + test( + 'creates conversation with fallback name for truly unknown user', + () async { + final conv = await ds.ensureConversation(participantId: 'not_in_map'); + expect(conv.participantName, 'Unknown User'); + expect(conv.lastMessagePreview, ''); + }, + ); + + test('newly created conversation appears in list', () async { + await ds.ensureConversation(participantId: 'u4'); + final convs = await ds.getConversations(); + expect(convs.any((c) => c.participantId == 'u4'), isTrue); + }); + }); + + // ── getUnreadCount ──────────────────────────────────────────────────────── + + group('getUnreadCount', () { + test('returns sum of unread counts across all conversations', () async { + final count = await ds.getUnreadCount(); + expect(count, 2); // c1=2, c2=0 + }); + + test('decreases after markMessagesAsRead', () async { + await ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'); + final count = await ds.getUnreadCount(); + expect(count, 0); + }); + }); + + // ── blockUser / unBlockUser / isBlocked ─────────────────────────────────── + + group('blockUser', () { + test('isBlocked returns false before blocking', () async { + expect(await ds.isBlocked('u3'), isFalse); + }); + + test('isBlocked returns true after blockUser', () async { + await ds.blockUser(userId: 'u3'); + expect(await ds.isBlocked('u3'), isTrue); + }); + + test('unBlockUser removes from blocked set', () async { + await ds.blockUser(userId: 'u3'); + await ds.unBlockUser(userId: 'u3'); + expect(await ds.isBlocked('u3'), isFalse); + }); + + test('isBlocked returns false for multiple unblocked users', () async { + expect(await ds.isBlocked('u4'), isFalse); + expect(await ds.isBlocked('u5'), isFalse); + }); + }); + + // ── isBlockedBy ─────────────────────────────────────────────────────────── + + group('isBlockedBy', () { + test('returns true for u2 who has blocked current user', () async { + expect(await ds.isBlockedBy('u2'), isTrue); + }); + + test('returns false for user who has not blocked current user', () async { + expect(await ds.isBlockedBy('u3'), isFalse); + }); + }); + + // ── markMessagesAsRead ──────────────────────────────────────────────────── + + group('markMessagesAsRead', () { + test('marks all messages in conversation as read', () async { + await ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'); + final (msgs, _) = await ds.getMessages(conversationId: 'c1'); + expect(msgs.every((m) => m.isRead), isTrue); + }); + + test('resets unReadCount to 0 for the conversation', () async { + await ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'); + final convs = await ds.getConversations(); + final c1 = convs.firstWhere((c) => c.conversationId == 'c1'); + expect(c1.unReadCount, 0); + }); + + test('no-ops gracefully for non-existing conversation', () async { + await ds.markMessagesAsRead( + conversationId: 'nonexistent', + messageId: 'mx', + ); + final convs = await ds.getConversations(); + expect(convs.length, 2); + }); + + test('preserves conversation metadata after mark as read', () async { + final before = (await ds.getConversations()).firstWhere( + (c) => c.conversationId == 'c1', + ); + await ds.markMessagesAsRead(conversationId: 'c1', messageId: 'm1'); + final after = (await ds.getConversations()).firstWhere( + (c) => c.conversationId == 'c1', + ); + expect(after.lastMessagePreview, before.lastMessagePreview); + expect(after.participantName, before.participantName); + }); + }); + + // ── getFollowings ───────────────────────────────────────────────────────── + + group('getFollowings', () { + test('returns followings for any userId', () async { + final users = await ds.getFollowings('any_id'); + expect(users.length, 5); + }); + + test('first following is Ali (u2)', () async { + final users = await ds.getFollowings('me'); + expect(users.first.participantId, 'u2'); + expect(users.first.participantName, 'Ali'); + }); + + test('followings include location when available', () async { + final users = await ds.getFollowings('me'); + final ali = users.firstWhere((u) => u.participantId == 'u2'); + expect(ali.location, 'Cairo'); + }); + + test('followings include null location when not set', () async { + final users = await ds.getFollowings('me'); + final omar = users.firstWhere((u) => u.participantId == 'u4'); + expect(omar.location, isNull); + }); + }); + + // ── getSearchedUsers ────────────────────────────────────────────────────── + + group('getSearchedUsers', () { + test('returns empty for empty query', () async { + final users = await ds.getSearchedUsers(''); + expect(users, isEmpty); + }); + + test('returns empty for whitespace-only query', () async { + final users = await ds.getSearchedUsers(' '); + expect(users, isEmpty); + }); + + test('returns matching users for valid query', () async { + final users = await ds.getSearchedUsers('ali'); + expect(users.length, 1); + expect(users.first.participantName, 'Ali'); + }); + + test('search is case-insensitive', () async { + final users = await ds.getSearchedUsers('ALI'); + expect(users.length, 1); + }); + + test('returns multiple matches when query is broad', () async { + final users = await ds.getSearchedUsers('a'); + expect(users.length, greaterThan(1)); + }); + + test('returns empty for no match', () async { + final users = await ds.getSearchedUsers('zzznomatch'); + expect(users, isEmpty); + }); + }); + + // ── getEmbeds ───────────────────────────────────────────────────────────── + + group('getEmbeds', () { + test('returns only tracks when embedType is track', () async { + final embeds = await ds.getEmbeds('u1', 'track'); + expect(embeds.isNotEmpty, isTrue); + expect(embeds.every((e) => e.embedType == 'track'), isTrue); + }); + + test('returns only playlists when embedType is playlist', () async { + final embeds = await ds.getEmbeds('u1', 'playlist'); + expect(embeds.isNotEmpty, isTrue); + expect(embeds.every((e) => e.embedType == 'playlist'), isTrue); + }); + + test('returns only albums when embedType is album', () async { + final embeds = await ds.getEmbeds('u1', 'album'); + expect(embeds.isNotEmpty, isTrue); + expect(embeds.every((e) => e.embedType == 'album'), isTrue); + }); + + test('returns empty for unknown embed type', () async { + final embeds = await ds.getEmbeds('u1', 'unknown'); + expect(embeds, isEmpty); + }); + + test('track embeds have artist name', () async { + final embeds = await ds.getEmbeds('u1', 'track'); + expect(embeds.first.artistName, isNotNull); + }); + }); + + // ── getTrackDetails ─────────────────────────────────────────────────────── + + group('getTrackDetails', () { + test('returns correct track for t1', () async { + final track = await ds.getTrackDetails('t1'); + expect(track.embedId, 't1'); + expect(track.embedType, 'track'); + expect(track.embedName, 'Bahaa Sultan - Ana Ghaltan'); + }); + + test('returns correct track for t2', () async { + final track = await ds.getTrackDetails('t2'); + expect(track.embedId, 't2'); + expect(track.artistName, 'Milan Drew'); + }); + + test('throws when track not found', () async { + expect(() => ds.getTrackDetails('nonexistent_track'), throwsStateError); + }); + }); + + // ── getPlaylistDetails ──────────────────────────────────────────────────── + + group('getPlaylistDetails', () { + test('returns correct playlist for p1', () async { + final pl = await ds.getPlaylistDetails('p1', 'playlist'); + expect(pl.embedId, 'p1'); + expect(pl.embedType, 'playlist'); + expect(pl.embedName, 'My Gym Set'); + }); + + test('returns correct album for a1', () async { + final album = await ds.getPlaylistDetails('a1', 'album'); + expect(album.embedId, 'a1'); + expect(album.embedType, 'album'); + }); + + test('throws when playlist not found for given type', () async { + expect( + () => ds.getPlaylistDetails('nonexistent', 'playlist'), + throwsStateError, + ); + }); + }); +} diff --git a/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart index 2814dc11..f5a8afc8 100644 --- a/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart +++ b/test/features/messaging/presentation/providers/messaging_providers_wiring_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:rythmify/features/messaging/data/repositories/repository_implement.dart'; import 'package:rythmify/features/messaging/domain/entities/conversation.dart'; import 'package:rythmify/features/messaging/domain/repositories/messaging_repository.dart'; import 'package:rythmify/features/messaging/presentation/providers/conversations_provider.dart'; @@ -40,6 +41,17 @@ void main() { ); } + group('repositoryprovider wiring', () { + test('builds the concrete repository implementation', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final repository = container.read(repositoryprovider); + + expect(repository, isA()); + }); + }); + group('conversationProvider wiring', () { test( 'executes GetConversationsUsecase and returns conversations', diff --git a/test/features/messaging/presentation/providers/socket_provider_test.dart b/test/features/messaging/presentation/providers/socket_provider_test.dart new file mode 100644 index 00000000..db19ef7c --- /dev/null +++ b/test/features/messaging/presentation/providers/socket_provider_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rythmify/features/authentication/domain/entities/user_entity.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; +import 'package:rythmify/features/authentication/presentation/providers/auth_state.dart'; +import 'package:rythmify/features/messaging/data/datasources/data_sources_sockets.dart'; +import 'package:rythmify/features/messaging/presentation/providers/socket_provider.dart'; + +class _FakeAuthNotifier extends AuthNotifier { + final AuthState _fixed; + _FakeAuthNotifier(this._fixed); + + @override + AuthState build() => _fixed; +} + +const _tUser = UserEntity( + id: 'u1', + email: 'test@example.com', + displayName: 'Test User', + isEmailVerified: true, + token: 'test-token', +); + +void main() { + group('socketProvider', () { + test('returns DataSourcesSockets instance', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthNotifier(const AuthUnauthenticated()), + ), + ], + ); + addTearDown(container.dispose); + + final socket = container.read(socketProvider); + expect(socket, isA()); + }); + + test('does not call connect when user is unauthenticated', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthNotifier(const AuthUnauthenticated()), + ), + ], + ); + addTearDown(container.dispose); + + // Reading the provider should not throw even though no connection is made + expect(() => container.read(socketProvider), returnsNormally); + }); + + test('disposes socket on container dispose', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthNotifier(const AuthUnauthenticated()), + ), + ], + ); + + container.read(socketProvider); + // dispose triggers ref.onDispose → socket.disconnect() + expect(() => container.dispose(), returnsNormally); + }); + + test( + 'rebuilds with new socket when auth state changes from null to null', + () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthNotifier(const AuthUnauthenticated()), + ), + ], + ); + addTearDown(container.dispose); + + final socket1 = container.read(socketProvider); + final socket2 = container.read(socketProvider); + expect(identical(socket1, socket2), isTrue); + }, + ); + + test('connects socket when user is authenticated', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthNotifier(const AuthAuthenticated(_tUser)), + ), + ], + ); + addTearDown(container.dispose); + + final socket = container.read(socketProvider); + expect(socket, isA()); + // socket.connect() was called (with real URL that will fail async — that's fine) + }); + }); +} diff --git a/test/features/settings/data/models/privacy_settings_model_test.dart b/test/features/settings/data/models/privacy_settings_model_test.dart index 33cbca70..1ebf99e5 100644 --- a/test/features/settings/data/models/privacy_settings_model_test.dart +++ b/test/features/settings/data/models/privacy_settings_model_test.dart @@ -173,7 +173,7 @@ void main() { expect(patch.containsKey('is_private'), false); }); - test('never includes show_top_fans_on_tracks even when changed', () { + test('includes show_top_fans_on_tracks when changed', () { const current = PrivacySettingsModel( isPrivate: false, receiveMessageFromAnyone: true, @@ -182,7 +182,8 @@ void main() { showTopFansOnTracks: false, // changed ); final patch = current.toPatch(base); - expect(patch.containsKey('show_top_fans_on_tracks'), false); + expect(patch['show_top_fans_on_tracks'], false); + expect(patch.length, 1); }); });