From 6fc084c53544dcee8f2dab5b45cf6becadb20258 Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Sat, 2 May 2026 16:04:51 +0300 Subject: [PATCH 1/2] lib/ test/ --- ai_scan.py | 146 ++++++++++ android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 27 ++ .../com/example/rythmify/LikesWidget.kt | 250 ++++++++++++++++++ .../com/example/rythmify/MainActivity.kt | 30 +++ .../com/example/rythmify/PlayerWidget.kt | 137 ++++++++++ .../src/main/res/drawable/ic_widget_pause.xml | 9 + .../src/main/res/drawable/ic_widget_play.xml | 9 + .../main/res/drawable/ic_widget_skip_next.xml | 9 + .../res/drawable/ic_widget_skip_previous.xml | 9 + .../res/drawable/likes_widget_background.xml | 6 + .../main/res/drawable/widget_background.xml | 6 + .../main/res/layout/likes_widget_feature.xml | 55 ++++ .../src/main/res/layout/likes_widget_mini.xml | 34 +++ .../src/main/res/layout/likes_widget_row.xml | 102 +++++++ .../app/src/main/res/layout/player_widget.xml | 104 ++++++++ android/app/src/main/res/values/strings.xml | 5 + .../src/main/res/xml/likes_widget_info.xml | 14 + .../src/main/res/xml/player_widget_info.xml | 13 + .../widget/likes_widget_sync_provider.dart | 89 +++++++ lib/core/widget/likes_widget_updater.dart | 30 +++ lib/core/widget/player_widget_updater.dart | 26 ++ lib/core/widget/widget_pin_service.dart | 25 ++ lib/core/widget/widget_sync_provider.dart | 68 +++++ .../feed/presentation/pages/home_screen.dart | 34 ++- .../unread_messages_count_provider.dart | 11 + .../widgets/pop_up_menu_widget.dart | 9 +- .../providers/notifications_provider.dart | 7 - .../providers/unread_count_provider.dart | 8 + .../widgets/notification_tile.dart | 14 +- .../presentation/pages/add_widget_screen.dart | 28 +- .../presentation/pages/settings_screen.dart | 46 +++- lib/main.dart | 5 + pubspec.lock | 8 + pubspec.yaml | 1 + .../datasource_implement_test.dart | 21 +- 36 files changed, 1377 insertions(+), 19 deletions(-) create mode 100644 ai_scan.py create mode 100644 android/app/src/main/kotlin/com/example/rythmify/LikesWidget.kt create mode 100644 android/app/src/main/kotlin/com/example/rythmify/PlayerWidget.kt create mode 100644 android/app/src/main/res/drawable/ic_widget_pause.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_play.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_skip_next.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_skip_previous.xml create mode 100644 android/app/src/main/res/drawable/likes_widget_background.xml create mode 100644 android/app/src/main/res/drawable/widget_background.xml create mode 100644 android/app/src/main/res/layout/likes_widget_feature.xml create mode 100644 android/app/src/main/res/layout/likes_widget_mini.xml create mode 100644 android/app/src/main/res/layout/likes_widget_row.xml create mode 100644 android/app/src/main/res/layout/player_widget.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/xml/likes_widget_info.xml create mode 100644 android/app/src/main/res/xml/player_widget_info.xml create mode 100644 lib/core/widget/likes_widget_sync_provider.dart create mode 100644 lib/core/widget/likes_widget_updater.dart create mode 100644 lib/core/widget/player_widget_updater.dart create mode 100644 lib/core/widget/widget_pin_service.dart create mode 100644 lib/core/widget/widget_sync_provider.dart create mode 100644 lib/features/messaging/presentation/providers/unread_messages_count_provider.dart create mode 100644 lib/features/notifications/presentation/providers/unread_count_provider.dart 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 ccbcdf5d..f04d1ed7 100644 --- a/lib/features/feed/presentation/pages/home_screen.dart +++ b/lib/features/feed/presentation/pages/home_screen.dart @@ -26,6 +26,8 @@ import '../widgets/shimmers/home_shimmer_screen.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 { @@ -108,6 +110,10 @@ class HomeScreen extends ConsumerWidget { final isLoading = asyncHome.isLoading && asyncHome.value == null; + final unreadMessages = ref.watch(unreadMessagesCountProvider); + final unreadNotifications = + ref.watch(unreadNotificationsCountProvider).asData?.value ?? 0; + return Scaffold( key: const Key('home_scaffold'), appBar: AppBar( @@ -183,7 +189,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'); }, @@ -192,7 +198,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'); }, @@ -275,4 +284,25 @@ 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), + ), + ), + ], + ); + } } 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/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..e1536d22 100644 --- a/lib/features/settings/presentation/pages/add_widget_screen.dart +++ b/lib/features/settings/presentation/pages/add_widget_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:rythmify/core/widget/widget_pin_service.dart'; /// Displays available home screen widgets the user can add. /// Shows two image cards: "Your likes" widget and "Player" widget. @@ -27,6 +28,18 @@ class AddWidgetScreen extends StatelessWidget { key: Key('add_you_likes_widget'), imgPath: 'assets/images/likes_widget.jpeg', height: 250, + 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,18 @@ class AddWidgetScreen extends StatelessWidget { key: Key('add_player_widget'), imgPath: 'assets/images/player_widget.jpeg', height: 210, + 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 +70,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 +80,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/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': []}, }), From 68d76aedc0e44272b7bc4cb61c0afac37aa9ceb2 Mon Sep 17 00:00:00 2001 From: RanaElgharabawy Date: Sat, 2 May 2026 16:08:12 +0300 Subject: [PATCH 2/2] Update home_screen.dart --- .../feed/presentation/pages/home_screen.dart | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/features/feed/presentation/pages/home_screen.dart b/lib/features/feed/presentation/pages/home_screen.dart index 2d40ef1c..9ba15aa9 100644 --- a/lib/features/feed/presentation/pages/home_screen.dart +++ b/lib/features/feed/presentation/pages/home_screen.dart @@ -302,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. @@ -380,25 +401,4 @@ class _NoInternetPlaceholder extends StatelessWidget { ), ); } - - 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), - ), - ), - ], - ); - } }