diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 19a2410..cdfa2c0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,6 @@
+ xmlns:tools="http://schemas.android.com/tools" >
@@ -15,7 +15,8 @@
android:label="@string/app_name"
android:supportsRtl="false"
android:theme="@style/Theme.ShiraBox"
- tools:targetApi="34">
+ tools:targetApi="34" >
+
-
-
-
-
-
-
-
-
-
+ android:theme="@style/Theme.ShiraBox" >
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/NotificationService.kt b/app/src/main/java/live/shirabox/shirabox/NotificationService.kt
new file mode 100644
index 0000000..7fef4d6
--- /dev/null
+++ b/app/src/main/java/live/shirabox/shirabox/NotificationService.kt
@@ -0,0 +1,143 @@
+package live.shirabox.shirabox
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.TaskStackBuilder
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import live.shirabox.core.entity.NotificationEntity
+import live.shirabox.core.model.ContentType
+import live.shirabox.core.util.Util
+import live.shirabox.shirabox.db.AppDatabase
+import live.shirabox.shirabox.ui.activity.resource.ResourceActivity
+import java.net.URL
+
+class NotificationService : FirebaseMessagingService() {
+ companion object {
+ private const val TAG = "ShiraBoxService"
+ private const val MAIN_CHANNEL_ID = "SB_NOTIFICATIONS"
+ lateinit var db: AppDatabase
+ }
+
+ private val job = SupervisorJob()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+
+ private data class MessageData(
+ val title: String,
+ val body: String,
+ val thumbnailUrl: String?,
+ val shikimoriId: Int
+ )
+
+ override fun onNewToken(token: String) {
+ super.onNewToken(token)
+ Log.d(TAG, "New token observed: $token")
+ }
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+ super.onMessageReceived(remoteMessage)
+ remoteMessage.data.ifEmpty { return }
+
+ Log.d(TAG, "Message data payload: ${remoteMessage.data}")
+
+ try {
+ db = AppDatabase.getAppDataBase(this)!!
+
+ val data = remoteMessage.data
+
+ val messageData = MessageData(
+ title = data["title"] ?: "Undefined title",
+ body = data["body"] ?: "Undefined notification body",
+ shikimoriId = data["shikimori_id"]!!.toInt(),
+ thumbnailUrl = data["thumbnail"]
+ )
+
+ scope.launch {
+ launch { sendNotification(messageData) }
+ launch { saveNotification(messageData) }
+ }
+
+ } catch (ex: Exception) {
+ ex.printStackTrace()
+ }
+ }
+
+ private suspend fun sendNotification(messageData: MessageData) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ manager.createNotificationChannel(notificationChannel(this))
+ }
+
+ val thumbnailBitmap = messageData.thumbnailUrl?.let {
+ withContext(Dispatchers.IO) {
+ async { Util.getBitmapFromURL(URL(it)) }
+ }.await()
+ }
+
+ val activityIntent = Intent(this, ResourceActivity::class.java).apply {
+ putExtra("id", messageData.shikimoriId)
+ putExtra("type", ContentType.ANIME.toString())
+ }
+ val activityPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
+ addNextIntentWithParentStack(activityIntent)
+ getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ }
+
+ val notification: Notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this@NotificationService, MAIN_CHANNEL_ID).apply {
+ setContentTitle(messageData.title)
+ setContentText(messageData.body)
+ setLargeIcon(thumbnailBitmap)
+ setSmallIcon(R.drawable.ic_stat_shirabox_notification)
+ setContentIntent(activityPendingIntent)
+ setAutoCancel(true)
+ }.build()
+ } else {
+ Notification.Builder(this@NotificationService).apply {
+ setContentTitle(messageData.title)
+ setContentText(messageData.body)
+ setLargeIcon(thumbnailBitmap)
+ setSmallIcon(R.drawable.ic_stat_shirabox_notification)
+ setContentIntent(activityPendingIntent)
+ setAutoCancel(true)
+ }.build()
+ }
+
+ manager.notify(System.nanoTime().toInt(), notification)
+ }
+
+ private suspend fun saveNotification(messageData: MessageData) {
+ withContext(Dispatchers.IO) {
+ db.notificationDao().insertNotification(
+ NotificationEntity(
+ contentShikimoriId = messageData.shikimoriId,
+ receiveTimestamp = System.currentTimeMillis(),
+ title = messageData.title,
+ body = messageData.body,
+ thumbnailUrl = messageData.thumbnailUrl ?: ""
+ )
+ )
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun notificationChannel(context: Context) = NotificationChannel(
+ MAIN_CHANNEL_ID,
+ context.getString(R.string.notificaion_channel),
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply { description = context.getString(R.string.notificaion_channel_description) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt b/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt
index 6cb4144..2345c15 100644
--- a/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt
+++ b/app/src/main/java/live/shirabox/shirabox/db/AppDatabase.kt
@@ -7,17 +7,21 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import live.shirabox.core.entity.ContentEntity
import live.shirabox.core.entity.EpisodeEntity
+import live.shirabox.core.entity.NotificationEntity
+import live.shirabox.core.util.Values
import live.shirabox.shirabox.db.dao.ContentDao
import live.shirabox.shirabox.db.dao.EpisodeDao
+import live.shirabox.shirabox.db.dao.NotificationDao
@Database(
- entities = [ContentEntity::class, EpisodeEntity::class],
+ entities = [ContentEntity::class, EpisodeEntity::class, NotificationEntity::class],
version = 1
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun contentDao(): ContentDao
abstract fun episodeDao(): EpisodeDao
+ abstract fun notificationDao(): NotificationDao
companion object {
private var INSTANCE: AppDatabase? = null
@@ -28,7 +32,7 @@ abstract class AppDatabase : RoomDatabase() {
INSTANCE = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
- "shirabox_db"
+ Values.DATABASE_NAME
).build()
}
}
diff --git a/app/src/main/java/live/shirabox/shirabox/db/dao/NotificationDao.kt b/app/src/main/java/live/shirabox/shirabox/db/dao/NotificationDao.kt
new file mode 100644
index 0000000..52dc8a0
--- /dev/null
+++ b/app/src/main/java/live/shirabox/shirabox/db/dao/NotificationDao.kt
@@ -0,0 +1,38 @@
+package live.shirabox.shirabox.db.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+import live.shirabox.core.entity.NotificationEntity
+import live.shirabox.core.entity.relation.NotificationAndContent
+
+@Dao
+interface NotificationDao {
+ @Query("SELECT * FROM notification")
+ fun all(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM notification")
+ fun allNotificationsWithContent(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM notification WHERE content_shikimori_id IS :shikimoriId")
+ fun notificationsFromParent(shikimoriId: Int): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.ABORT)
+ fun insertNotification(vararg notificationEntity: NotificationEntity)
+
+ @Update
+ fun updateNotification(vararg notificationEntity: NotificationEntity)
+
+ @Delete
+ fun deleteNotification(vararg notificationEntity: NotificationEntity)
+
+ @Query("DELETE FROM notification")
+ fun deleteAll()
+}
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt
index 0efede4..c2a273e 100644
--- a/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt
+++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/MainActivity.kt
@@ -9,19 +9,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import live.shirabox.shirabox.ui.component.navigation.BottomNavigationView
+import live.shirabox.shirabox.ui.screen.explore.notifications.NotificationsDialog
import live.shirabox.shirabox.ui.theme.ShiraBoxTheme
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalFoundationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
setContent {
ShiraBoxTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
- ) { BottomNavigationView() }
+ ) {
+ NotificationsDialog()
+ BottomNavigationView()
+ }
}
}
}
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt
index 7fbdc14..ea625b3 100644
--- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt
+++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceActivity.kt
@@ -161,6 +161,7 @@ fun Resource(
LaunchedEffect(Unit) {
model.fetchContent(id)
model.fetchRelated(id)
+ model.clearNotifications(id)
}
AnimatedVisibility(
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt
index eec8a74..9bedb75 100644
--- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt
+++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceSheetScreens.kt
@@ -209,7 +209,7 @@ fun SourcesSheetScreen(
coverImage = source.icon,
trailingIcon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin,
onTrailingIconClick = {
- model.switchSourcePinStatus(content.shikimoriID, source)
+ model.switchSourcePinStatus(context, content.shikimoriID, source)
}
) {
currentSheetScreenState.value =
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt
index ae16bc4..20a1158 100644
--- a/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt
+++ b/app/src/main/java/live/shirabox/shirabox/ui/activity/resource/ResourceViewModel.kt
@@ -6,16 +6,23 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.google.firebase.ktx.Firebase
+import com.google.firebase.messaging.ktx.messaging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import live.shirabox.core.datastore.AppDataStore
+import live.shirabox.core.datastore.DataStoreScheme
import live.shirabox.core.entity.EpisodeEntity
import live.shirabox.core.model.Content
import live.shirabox.core.model.ContentType
+import live.shirabox.core.util.Util
import live.shirabox.core.util.Util.Companion.mapContentToEntity
import live.shirabox.core.util.Util.Companion.mapEntityToContent
import live.shirabox.data.DataSources
@@ -131,15 +138,45 @@ class ResourceViewModel(context: Context, private val contentType: ContentType)
}
}
- fun switchSourcePinStatus(id: Int, source: AbstractContentRepository) {
+ fun switchSourcePinStatus(context: Context, id: Int, repository: AbstractContentRepository) {
viewModelScope.launch(Dispatchers.IO) {
val content = db?.contentDao()?.getContent(id)
- content?.let {
- if (pinnedSources.contains(source.name)) pinnedSources.remove(source.name) else pinnedSources.add(
- source.name
+ val subscriptionAllowed =
+ AppDataStore.read(context, DataStoreScheme.FIELD_SUBSCRIPTION.key).firstOrNull()
+ ?: DataStoreScheme.FIELD_SUBSCRIPTION.defaultValue
+
+ content?.let { entity ->
+ val contentTopic = Util.encodeTopic(
+ repository = repository.name,
+ actingTeam = repository.name,
+ contentEnName = entity.enName
)
- db?.contentDao()?.updateContents(it.copy(pinnedSources = pinnedSources))
+ when (pinnedSources.contains(repository.name)) {
+ true -> {
+ pinnedSources.remove(repository.name)
+ if(subscriptionAllowed) Firebase.messaging.unsubscribeFromTopic(contentTopic)
+ }
+ else -> {
+ pinnedSources.add(repository.name)
+ if(subscriptionAllowed) Firebase.messaging.subscribeToTopic(contentTopic)
+ }
+ }
+
+ db?.contentDao()?.updateContents(entity.copy(pinnedSources = pinnedSources))
+ }
+ }
+ }
+
+ fun clearNotifications(shikimoriId: Int) {
+ viewModelScope.launch(Dispatchers.IO) {
+ db?.notificationDao()?.notificationsFromParent(shikimoriId)?.catch {
+ it.printStackTrace()
+ emitAll(emptyFlow())
+ }?.map {
+ it.toTypedArray()
+ }?.collect {
+ db.notificationDao().deleteNotification(*it)
}
}
}
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/general/Notifications.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/general/Notifications.kt
new file mode 100644
index 0000000..4fd64f4
--- /dev/null
+++ b/app/src/main/java/live/shirabox/shirabox/ui/component/general/Notifications.kt
@@ -0,0 +1,126 @@
+package live.shirabox.shirabox.ui.component.general
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.NotificationsActive
+import androidx.compose.material.icons.outlined.NotificationsOff
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import live.shirabox.shirabox.R
+
+@Composable
+fun NotificationsRequestDialog(isOpen: MutableState, onConfirm: () -> Unit) {
+ if(isOpen.value) {
+ AlertDialog(
+ onDismissRequest = {
+ isOpen.value = false
+ },
+ icon = {
+ Icon(
+ modifier = Modifier.size(48.dp),
+ imageVector = Icons.Outlined.NotificationsActive,
+ tint = MaterialTheme.colorScheme.primary,
+ contentDescription = null
+ )
+ },
+ title = {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.notifications_request),
+ textAlign = TextAlign.Center
+ )
+ },
+ text = {
+ Text(
+ stringResource(R.string.notifications_request_text)
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ isOpen.value = false
+ onConfirm()
+ }
+ ) {
+ Text(stringResource(R.string.enable_notifications))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ isOpen.value = false
+ }
+ ) {
+ Text(stringResource(R.string.disable_notifications))
+ }
+ }
+ )
+ }
+}
+
+@Composable
+fun NotificationsDismissDialog(context: Context, isOpen: MutableState) {
+ if(isOpen.value) {
+ AlertDialog(
+ onDismissRequest = {
+ isOpen.value = false
+ },
+ icon = {
+ Icon(
+ modifier = Modifier.size(48.dp),
+ imageVector = Icons.Outlined.NotificationsOff,
+ tint = MaterialTheme.colorScheme.primary,
+ contentDescription = null
+ )
+ },
+ title = {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.notifications_disabled),
+ textAlign = TextAlign.Center
+ )
+ },
+ text = {
+ Text(
+ stringResource(R.string.notifications_disabled_text)
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ isOpen.value = false
+ }
+ ) {
+ Text(stringResource(R.string.notifications_disabled_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ isOpen.value = false
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.parse("package:${context.applicationContext.packageName}")
+ }
+ context.startActivity(intent)
+ }
+ ) {
+ Text(stringResource(R.string.notifications_request))
+ }
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt
index 9fdf6cf..2843a56 100644
--- a/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt
+++ b/app/src/main/java/live/shirabox/shirabox/ui/component/navigation/ShiraNavHost.kt
@@ -7,7 +7,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import live.shirabox.shirabox.ui.screen.explore.ExploreScreen
-import live.shirabox.shirabox.ui.screen.explore.NotificationsScreen
+import live.shirabox.shirabox.ui.screen.explore.notifications.NotificationsScreen
import live.shirabox.shirabox.ui.screen.favourites.FavouritesScreen
import live.shirabox.shirabox.ui.screen.profile.ProfileScreen
import live.shirabox.shirabox.ui.screen.profile.history.History
@@ -22,6 +22,6 @@ fun ShiraBoxNavHost(navController: NavHostController){
composable(BottomNavItems.Profile.route) { ProfileScreen(navController) }
}
composable(NestedNavItems.History.route) { History(navController) }
- composable(NestedNavItems.Notifications.route) { NotificationsScreen() }
+ composable(NestedNavItems.Notifications.route) { NotificationsScreen(navController) }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt b/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt
index b8f9332..11cbfe3 100644
--- a/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt
+++ b/app/src/main/java/live/shirabox/shirabox/ui/component/top/TopBar.kt
@@ -9,10 +9,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.NotificationsNone
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -27,16 +30,33 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.emptyFlow
+import live.shirabox.core.util.Util
import live.shirabox.shirabox.R
import live.shirabox.shirabox.ui.activity.search.SearchActivity
import live.shirabox.shirabox.ui.component.navigation.NestedNavItems
+import live.shirabox.shirabox.ui.screen.explore.notifications.NotificationsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun TopBar(navController: NavController?) {
+fun TopBar(
+ navController: NavController,
+ model: NotificationsViewModel = viewModel(factory = Util.viewModelFactory {
+ NotificationsViewModel(context = navController.context.applicationContext)
+ })
+) {
val context = LocalContext.current
+ val notifications = model.allNotificationsFlow().catch {
+ it.printStackTrace()
+ emitAll(emptyFlow())
+ }.collectAsStateWithLifecycle(initialValue = emptyList())
+
Row(
modifier = Modifier
.padding(16.dp, 16.dp, 16.dp, 0.dp),
@@ -71,14 +91,23 @@ fun TopBar(navController: NavController?) {
}
IconButton(
- onClick = { navController?.navigate(NestedNavItems.Notifications.route) }
+ modifier = Modifier.requiredSize(48.dp),
+ onClick = { navController.navigate(NestedNavItems.Notifications.route) }
) {
- Icon(
- modifier = Modifier.size(24.dp),
- imageVector = Icons.Rounded.NotificationsNone,
- contentDescription = "Notifications",
- tint = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ BadgedBox(
+ badge = {
+ if (notifications.value.isNotEmpty()) Badge {
+ Text(text = Util.formatBadgeNumber(notifications.value.size))
+ }
+ }) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = Icons.Rounded.NotificationsNone,
+ contentDescription = "Notifications",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt
index 9395b27..bec3d8c 100644
--- a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt
+++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/BaseMediaScreen.kt
@@ -257,7 +257,7 @@ private fun PopularsGrid(isReady: Boolean, contents: List) {
ResourceActivity::class.java
).apply {
putExtra("id", it.shikimoriID)
- putExtra("type", it.type)
+ putExtra("type", it.type.toString())
}
)
}
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/NotificationsScreen.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/NotificationsScreen.kt
deleted file mode 100644
index 2e9622e..0000000
--- a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/NotificationsScreen.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package live.shirabox.shirabox.ui.screen.explore
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.res.imageResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import live.shirabox.shirabox.R
-import live.shirabox.shirabox.ui.component.general.ListItem
-import live.shirabox.shirabox.ui.component.top.TopBar
-
-@Composable
-fun NotificationsScreen(){
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- TopBar(null)
-
- Column(
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- modifier = Modifier.padding(16.dp, 0.dp),
- text = stringResource(R.string.notifications),
- fontSize = 22.sp,
- fontWeight = FontWeight(500)
- )
- Text(
- modifier = Modifier.padding(16.dp, 0.dp),
- text = "05.05.2023",
- fontSize = 15.sp,
- fontWeight = FontWeight(500)
- )
- repeat(4) {
- ListItem(
- headlineContent = {
- Box(Modifier.fillMaxWidth()) {
- Text(modifier = Modifier.align(Alignment.TopStart),
- text = "Название")
- Text(modifier = Modifier.align(Alignment.CenterEnd),
- text = "14:32",
- fontSize = 12.sp)
- }
- },
- supportingString = "Вышла новая серия 10 от Unknown Team!",
- coverImage = ImageBitmap.imageResource(id = R.drawable.blank)
- ) {
- /* TODO */
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsDialog.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsDialog.kt
new file mode 100644
index 0000000..8c6aee2
--- /dev/null
+++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsDialog.kt
@@ -0,0 +1,56 @@
+package live.shirabox.shirabox.ui.screen.explore.notifications
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import live.shirabox.shirabox.ui.component.general.NotificationsDismissDialog
+import live.shirabox.shirabox.ui.component.general.NotificationsRequestDialog
+
+@Composable
+fun NotificationsDialog() {
+ val context = LocalContext.current
+ val activity = context as Activity
+
+ val openRequestDialog = remember { mutableStateOf(false) }
+ val openDismissDialog = remember { mutableStateOf(false) }
+
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = { isGranted: Boolean ->
+ openDismissDialog.value = !isGranted
+ }
+ )
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val showRationale = !ActivityCompat.shouldShowRequestPermissionRationale(
+ activity, Manifest.permission.POST_NOTIFICATIONS)
+
+ if(!isNotificationsPermissionGranted(context) && showRationale){
+ openRequestDialog.value = true
+ }
+
+ NotificationsRequestDialog(isOpen = openRequestDialog) {
+ launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ NotificationsDismissDialog(context = context, isOpen = openDismissDialog)
+}
+
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+private fun isNotificationsPermissionGranted(context: Context): Boolean =
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsScreen.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsScreen.kt
new file mode 100644
index 0000000..0653673
--- /dev/null
+++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsScreen.kt
@@ -0,0 +1,182 @@
+package live.shirabox.shirabox.ui.screen.explore.notifications
+
+import android.content.Intent
+import android.text.format.DateUtils
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavController
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.emptyFlow
+import live.shirabox.core.util.Util
+import live.shirabox.shirabox.R
+import live.shirabox.shirabox.ui.activity.resource.ResourceActivity
+import live.shirabox.shirabox.ui.component.general.ListItem
+import live.shirabox.shirabox.ui.component.top.TopBar
+import java.text.DateFormat
+import java.text.SimpleDateFormat
+import java.util.Date
+
+@Composable
+fun NotificationsScreen(
+ navController: NavController,
+ model: NotificationsViewModel = viewModel(factory = Util.viewModelFactory {
+ NotificationsViewModel(context = navController.context.applicationContext)
+ })
+){
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ TopBar(navController)
+ val context = LocalContext.current
+
+ val notifications = model.notificationsWithContentFlow().catch {
+ it.printStackTrace()
+ emitAll(emptyFlow())
+ }.collectAsStateWithLifecycle(initialValue = emptyList())
+
+ val filteredNotificationsByDate by remember(notifications.value) {
+ derivedStateOf {
+ notifications.value.groupBy {
+ when (DateUtils.isToday(it.notificationEntity.receiveTimestamp)) {
+ true -> context.resources.getString(R.string.today)
+ false -> DateUtils.formatSameDayTime(
+ it.notificationEntity.receiveTimestamp,
+ System.currentTimeMillis(),
+ DateFormat.SHORT,
+ DateFormat.SHORT
+ ).toString()
+ }
+ }
+ }
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp, 0.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(R.string.notifications),
+ fontSize = 22.sp,
+ fontWeight = FontWeight(500)
+ )
+
+ Button(
+ enabled = notifications.value.isNotEmpty(),
+ onClick = {
+ model.clearNotifications()
+ }
+ ) {
+ Text(stringResource(id = R.string.clear_notifications))
+ }
+ }
+
+ if(notifications.value.isEmpty()) {
+ Column(
+ modifier = Modifier
+ .padding(64.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ modifier = Modifier.size(64.dp),
+ imageVector = Icons.Default.AutoAwesome,
+ contentDescription = "awesome",
+ tint = MaterialTheme.colorScheme.surfaceTint.copy(0.4f)
+ )
+ Text(
+ text = stringResource(id = R.string.no_notifications),
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Normal,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ filteredNotificationsByDate.forEach { entry ->
+ Text(
+ modifier = Modifier
+ .padding(16.dp, 0.dp)
+ .fillMaxWidth(),
+ text = entry.key,
+ textAlign = TextAlign.Left,
+ fontSize = 15.sp,
+ fontWeight = FontWeight(500)
+ )
+
+ entry.value.forEach {
+ val languageCode = Locale.current.language
+
+ val time = SimpleDateFormat(
+ "H:mm",
+ java.util.Locale(languageCode)
+ ).format(Date(it.notificationEntity.receiveTimestamp))
+
+ ListItem(
+ headlineContent = {
+ Box(Modifier.fillMaxWidth()) {
+ Text(modifier = Modifier.align(Alignment.TopStart),
+ text = it.contentEntity.name)
+ Text(modifier = Modifier.align(Alignment.CenterEnd),
+ text = time,
+ fontSize = 12.sp)
+ }
+ },
+ supportingString = it.notificationEntity.body,
+ coverImage = it.contentEntity.image
+ ) {
+ context.startActivity(
+ Intent(
+ context,
+ ResourceActivity::class.java
+ ).apply {
+ putExtra("id", it.contentEntity.shikimoriID)
+ putExtra("type", it.contentEntity.type.toString())
+ }
+ )
+ model.removeNotification(it.notificationEntity)
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsViewModel.kt b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsViewModel.kt
new file mode 100644
index 0000000..b8ed567
--- /dev/null
+++ b/app/src/main/java/live/shirabox/shirabox/ui/screen/explore/notifications/NotificationsViewModel.kt
@@ -0,0 +1,34 @@
+package live.shirabox.shirabox.ui.screen.explore.notifications
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.launch
+import live.shirabox.core.entity.NotificationEntity
+import live.shirabox.core.entity.relation.NotificationAndContent
+import live.shirabox.shirabox.db.AppDatabase
+
+class NotificationsViewModel(context: Context): ViewModel() {
+ private val db = AppDatabase.getAppDataBase(context)
+
+ fun allNotificationsFlow(): Flow> =
+ db?.notificationDao()?.all() ?: emptyFlow()
+
+ fun notificationsWithContentFlow(): Flow> =
+ db?.notificationDao()?.allNotificationsWithContent() ?: emptyFlow()
+
+ fun removeNotification(entity: NotificationEntity) {
+ viewModelScope.launch(Dispatchers.IO) {
+ db?.notificationDao()?.deleteNotification(entity)
+ }
+ }
+
+ fun clearNotifications() {
+ viewModelScope.launch(Dispatchers.IO) {
+ db?.notificationDao()?.deleteAll()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8705b80..9e45d2b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -28,6 +28,8 @@
Среди новинок
Популярно
Уведомления
+ Сегодня
+
+ Новые серии
+ Уведомления о новых выпусках эпизодов
+
@@ -98,14 +106,14 @@
Ничего не найдено
Включить уведомления
- Вы сможете получать уведомления о выходе новых серий и обновлений ShiraBox. Обещаем, мы не будем надоедать вам ❤️️
+ Вы сможете получать уведомления о выходе новых серий и обновлений ShiraBox. \nОбещаем, мы не будем надоедать вам!
Включить уведомления
Нет, спасибо
Уведомления отключены
- Вы больше не сможете получать уведомления о выходе новых серий и обновлений ShiraBox. Обещаем, мы не будем надоедать вам ❤️
+ Вы больше не сможете получать уведомления о выходе новых серий и обновлений ShiraBox. \nУведомления можно включить перейдя в раздел приложений в настройках вашего устройства.
Хорошо
Очистить
Уведомлений нет
diff --git a/build.gradle.kts b/build.gradle.kts
index 200ecd2..332a87d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,3 +1,8 @@
+buildscript {
+ dependencies {
+ classpath("com.google.gms:google-services:4.4.1")
+ }
+}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id ("com.android.application") version "8.3.1" apply false
diff --git a/core/src/main/java/live/shirabox/core/entity/NotificationEntity.kt b/core/src/main/java/live/shirabox/core/entity/NotificationEntity.kt
new file mode 100644
index 0000000..4c78f91
--- /dev/null
+++ b/core/src/main/java/live/shirabox/core/entity/NotificationEntity.kt
@@ -0,0 +1,15 @@
+package live.shirabox.core.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "notification")
+data class NotificationEntity(
+ @PrimaryKey(autoGenerate = true) val uid: Long = 0,
+ @ColumnInfo(name = "content_shikimori_id") val contentShikimoriId: Int,
+ @ColumnInfo(name = "receive_timestamp") val receiveTimestamp: Long,
+ @ColumnInfo(name = "title") val title: String,
+ @ColumnInfo(name = "body") val body: String,
+ @ColumnInfo(name = "thumbnail") val thumbnailUrl: String
+)
\ No newline at end of file
diff --git a/core/src/main/java/live/shirabox/core/entity/relation/NotificationAndContent.kt b/core/src/main/java/live/shirabox/core/entity/relation/NotificationAndContent.kt
new file mode 100644
index 0000000..103d7f9
--- /dev/null
+++ b/core/src/main/java/live/shirabox/core/entity/relation/NotificationAndContent.kt
@@ -0,0 +1,16 @@
+package live.shirabox.core.entity.relation
+
+import androidx.room.Embedded
+import androidx.room.Relation
+import live.shirabox.core.entity.ContentEntity
+import live.shirabox.core.entity.NotificationEntity
+
+data class NotificationAndContent (
+ @Embedded val notificationEntity: NotificationEntity,
+
+ @Relation(
+ parentColumn = "content_shikimori_id",
+ entityColumn = "shikimori_id"
+ )
+ val contentEntity: ContentEntity
+)
\ No newline at end of file
diff --git a/core/src/main/java/live/shirabox/core/serializable/Topic.kt b/core/src/main/java/live/shirabox/core/serializable/Topic.kt
new file mode 100644
index 0000000..83a00ea
--- /dev/null
+++ b/core/src/main/java/live/shirabox/core/serializable/Topic.kt
@@ -0,0 +1,11 @@
+package live.shirabox.core.serializable
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Topic(
+ val repository: String,
+ @SerialName("acting_team") val actingTeam: String,
+ val md5: String
+)
\ No newline at end of file
diff --git a/core/src/main/java/live/shirabox/core/util/Util.kt b/core/src/main/java/live/shirabox/core/util/Util.kt
index e444af9..56222bc 100644
--- a/core/src/main/java/live/shirabox/core/util/Util.kt
+++ b/core/src/main/java/live/shirabox/core/util/Util.kt
@@ -3,6 +3,8 @@ package live.shirabox.core.util
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.os.Build
import android.text.Html
import androidx.core.view.WindowInsetsControllerCompat
@@ -10,10 +12,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.media3.common.C
import com.google.accompanist.systemuicontroller.SystemUiController
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
import live.shirabox.core.entity.ContentEntity
import live.shirabox.core.model.Content
+import live.shirabox.core.serializable.Topic
+import java.net.URL
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
+
class Util {
companion object {
fun hideSystemUi(controller: SystemUiController) {
@@ -100,6 +112,37 @@ class Util {
)
}
+ @OptIn(ExperimentalEncodingApi::class)
+ fun encodeTopic(repository: String, actingTeam: String, contentEnName: String): String {
+ val json = Json.encodeToString(
+ Topic(
+ repository = repository,
+ actingTeam = actingTeam,
+ md5 = contentEnName.md5()
+ )
+ )
+
+ return Base64.encode(json.toByteArray())
+ }
+ suspend fun getBitmapFromURL(url: URL): Bitmap? {
+ return try {
+ val inputStream = withContext(Dispatchers.IO) {
+ async {
+ val connection = url.openConnection()
+ connection.setDoInput(true)
+ connection.connect()
+ connection.inputStream
+ }
+ }
+
+ BitmapFactory.decodeStream(inputStream.await())
+ } catch (_: Exception) { null }
+ }
+
+ fun formatBadgeNumber(number: Int): String {
+ return if (number < 9) number.toString() else "9+"
+ }
+
inline fun viewModelFactory(crossinline f: () -> VM) =
object : ViewModelProvider.Factory {
override fun create(modelClass: Class): T = f() as T
diff --git a/core/src/main/java/live/shirabox/core/util/Values.kt b/core/src/main/java/live/shirabox/core/util/Values.kt
index f3f2569..f727ac4 100644
--- a/core/src/main/java/live/shirabox/core/util/Values.kt
+++ b/core/src/main/java/live/shirabox/core/util/Values.kt
@@ -4,6 +4,7 @@ class Values {
companion object {
const val INSTANT_SEEK_TIME = 10000
const val CONTROLS_HIDE_DELAY = 3000L
+ const val DATABASE_NAME = "shirabox_db"
const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-A127F) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/21.0 Chrome/110.0.5481.154 Mobile Safari/537.36"
}
}
\ No newline at end of file