diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fe845f7 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "se.ajpanton.notificationlog" + compileSdk = 36 + + defaultConfig { + applicationId = "se.ajpanton.notificationlog" + minSdk = 34 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + viewBinding = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } +} + +dependencies { + implementation(libs.appcompat) + implementation(libs.core.ktx) + implementation(libs.fragment.ktx) + implementation(libs.material) + implementation(libs.drawerlayout) + + testImplementation(libs.junit) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..020dd91 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt new file mode 100644 index 0000000..2309483 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt @@ -0,0 +1,262 @@ +package se.ajpanton.notificationlog + +import android.os.Bundle +import android.content.res.Configuration +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.graphics.Insets +import androidx.core.view.GravityCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.commit +import com.google.android.material.navigation.NavigationView +import se.ajpanton.notificationlog.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { + private lateinit var binding: ActivityMainBinding + private lateinit var drawerToggle: ActionBarDrawerToggle + private var permanentSidebar = false + private var currentItemId = R.id.nav_view_logs + private var drawerNavigationBasePaddingLeft: Int? = null + private var permanentNavigationBasePaddingLeft: Int? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Keep every app surface below the system bars rather than relying on edge-to-edge drawing. + WindowCompat.setDecorFitsSystemWindows(window, true) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode + setSupportActionBar(binding.toolbar) + + setupNavigationView(binding.navViewDrawer) + setupNavigationView(binding.navViewPermanent) + drawerToggle = ActionBarDrawerToggle( + this, + binding.drawerLayout, + binding.toolbar, + R.string.navigation_open, + R.string.navigation_close, + ) + binding.drawerLayout.addDrawerListener(drawerToggle) + binding.toolbar.setNavigationOnClickListener { + if (!permanentSidebar) { + binding.drawerLayout.openDrawer(GravityCompat.START) + } + } + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!permanentSidebar && binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.drawerLayout.closeDrawer(GravityCompat.START) + return + } + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + }) + + currentItemId = savedInstanceState?.getInt(STATE_CURRENT_ITEM_ID) ?: R.id.nav_view_logs + navigateTo(currentItemId) + applyNavigationDrawerInsets() + syncDrawerToggle() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(STATE_CURRENT_ITEM_ID, currentItemId) + super.onSaveInstanceState(outState) + } + + override fun onNavigationItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.nav_settings_header) { + return false + } + navigateTo(item.itemId) + if (!permanentSidebar) { + binding.drawerLayout.closeDrawer(GravityCompat.START) + } + return true + } + + private fun navigateTo(itemId: Int) { + currentItemId = itemId + syncDrawerSelection(itemId) + val page = Page.fromMenuItem(itemId) + supportFragmentManager.commit { + replace(R.id.content_frame, PageFragment.newInstance(page.titleRes)) + runOnCommit(::applyPageTitleVisibility) + } + title = getString(page.titleRes) + } + + private fun setupNavigationView(navigationView: NavigationView) { + navigationView.setNavigationItemSelectedListener(this) + navigationView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateNavigationHeaderHeight(navigationView) + } + } + + private fun syncDrawerSelection(itemId: Int) { + syncNavigationSelection(binding.navViewDrawer, itemId) + syncNavigationSelection(binding.navViewPermanent, itemId) + } + + private fun syncNavigationSelection(navigationView: NavigationView, itemId: Int) { + val menu = navigationView.menu ?: return + for (index in 0 until menu.size()) { + val item = menu.getItem(index) + if (item.itemId == R.id.nav_settings_header) { + item.isCheckable = false + } else { + item.isCheckable = true + item.isChecked = item.itemId == itemId + } + } + navigationView.invalidate() + } + + private fun applyNavigationDrawerInsets() { + binding.drawerLayout.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + applyNavigationDrawerMarginsFromRootInsets() + } + binding.drawerLayout.post(::applyNavigationDrawerMarginsFromRootInsets) + } + + private fun applyNavigationDrawerMarginsFromRootInsets() { + val rootInsets = ViewCompat.getRootWindowInsets(binding.drawerLayout) + val safeInsets = rootInsets?.getInsetsIgnoringVisibility( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) ?: Insets.NONE + applyNavigationDrawerSafeArea(safeInsets.left, safeInsets.right) + } + + private fun applyNavigationDrawerSafeArea(leftInset: Int, rightInset: Int) { + val safeLeft = maxOf(0, leftInset, binding.drawerLayout.paddingLeft) + val safeRight = maxOf(0, rightInset, binding.drawerLayout.paddingRight) + binding.drawerLayout.setDrawerSafeInsets(safeLeft, safeRight) + drawerNavigationBasePaddingLeft = applyNavigationLeftPadding( + binding.navViewDrawer, + drawerNavigationBasePaddingLeft, + safeLeft, + ) + permanentNavigationBasePaddingLeft = applyNavigationLeftPadding( + binding.navViewPermanent, + permanentNavigationBasePaddingLeft, + 0, + ) + updatePermanentSidebarMode() + } + + private fun applyNavigationLeftPadding( + navigationView: NavigationView, + basePaddingLeft: Int?, + safeLeft: Int, + ): Int { + val base = basePaddingLeft ?: navigationView.paddingLeft + val leftPadding = base + safeLeft + if (navigationView.paddingLeft != leftPadding || navigationView.translationX != 0f) { + navigationView.translationX = 0f + navigationView.setPadding( + leftPadding, + navigationView.paddingTop, + navigationView.paddingRight, + navigationView.paddingBottom, + ) + navigationView.requestLayout() + } + return base + } + + private fun updatePermanentSidebarMode() { + val rootWidth = binding.drawerLayout.width + if (rootWidth <= 0) { + return + } + val drawerWidth = resources.getDimensionPixelSize(R.dimen.navigation_drawer_width) + val foldedPortraitWidth = resources.getDimensionPixelSize(R.dimen.folded_portrait_width) + val shouldUsePermanentSidebar = rootWidth >= drawerWidth + + (foldedPortraitWidth * PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER).toInt() + + if (permanentSidebar != shouldUsePermanentSidebar) { + permanentSidebar = shouldUsePermanentSidebar + drawerToggle.isDrawerIndicatorEnabled = !permanentSidebar + binding.toolbar.visibility = if (permanentSidebar) View.GONE else View.VISIBLE + if (permanentSidebar) { + binding.navViewPermanent.visibility = View.VISIBLE + binding.drawerLayout.closeDrawer(GravityCompat.START, false) + } else { + binding.drawerLayout.closeDrawer(GravityCompat.START, false) + binding.navViewPermanent.visibility = View.GONE + } + syncDrawerToggle() + } + if (permanentSidebar) { + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START) + binding.navViewDrawer.visibility = View.GONE + } else { + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) + binding.navViewDrawer.visibility = View.VISIBLE + } + updateNavigationHeaderHeight(binding.navViewDrawer) + updateNavigationHeaderHeight(binding.navViewPermanent) + applyPageTitleVisibility() + } + + private fun updateNavigationHeaderHeight(navigationView: NavigationView) { + if (navigationView.headerCount == 0 || navigationView.height <= 0) { + return + } + val menuRows = countVisibleMenuRows(navigationView.menu) + val menuHeight = menuRows * resources.getDimensionPixelSize(R.dimen.navigation_menu_item_height) + val desiredHeaderHeight = (navigationView.height - menuHeight).coerceIn( + resources.getDimensionPixelSize(R.dimen.drawer_header_min_height), + resources.getDimensionPixelSize(R.dimen.drawer_header_max_height), + ) + val header = navigationView.getHeaderView(0) ?: return + if (header.layoutParams.height != desiredHeaderHeight) { + header.layoutParams.height = desiredHeaderHeight + header.requestLayout() + } + } + + private fun countVisibleMenuRows(menu: Menu): Int = (0 until menu.size()).count { menu.getItem(it).isVisible } + + private fun applyPageTitleVisibility() { + (supportFragmentManager.findFragmentById(R.id.content_frame) as? PageFragment) + ?.setPageTitleVisible(permanentSidebar) + } + + private fun syncDrawerToggle() { + drawerToggle.syncState() + drawerToggle.drawerArrowDrawable.color = getColor(R.color.on_primary) + } + + private enum class Page(val menuId: Int, val titleRes: Int) { + VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs), + SETTINGS(R.id.nav_settings, R.string.page_settings), + APPEARING(R.id.nav_appearing, R.string.page_appearing), + DISAPPEARING(R.id.nav_disappearing, R.string.page_disappearing), + TEXT_CONTENT(R.id.nav_text_content, R.string.page_text_content), + IMAGE_CONTENT(R.id.nav_image_content, R.string.page_image_content), + EDITS(R.id.nav_edits, R.string.page_edits), + ; + + companion object { + fun fromMenuItem(menuId: Int): Page = entries.firstOrNull { it.menuId == menuId } ?: VIEW_LOGS + } + } + + private companion object { + const val STATE_CURRENT_ITEM_ID = "current_item_id" + const val PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER = 1.1f + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/PageFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/PageFragment.kt new file mode 100644 index 0000000..ab8e121 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/PageFragment.kt @@ -0,0 +1,34 @@ +package se.ajpanton.notificationlog + +import android.os.Bundle +import android.view.View +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import se.ajpanton.notificationlog.databinding.FragmentPageBinding + +class PageFragment : Fragment(R.layout.fragment_page) { + private var binding: FragmentPageBinding? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding = FragmentPageBinding.bind(view) + binding?.pageTitle?.setText(requireArguments().getInt(ARG_TITLE)) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + fun setPageTitleVisible(visible: Boolean) { + binding?.pageTitle?.visibility = if (visible) View.VISIBLE else View.GONE + } + + companion object { + private const val ARG_TITLE = "title" + + fun newInstance(@StringRes titleRes: Int) = PageFragment().apply { + arguments = Bundle().apply { putInt(ARG_TITLE, titleRes) } + } + } +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/SafeInsetDrawerLayout.kt b/app/src/main/java/se/ajpanton/notificationlog/SafeInsetDrawerLayout.kt new file mode 100644 index 0000000..140910d --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/SafeInsetDrawerLayout.kt @@ -0,0 +1,41 @@ +package se.ajpanton.notificationlog + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import androidx.drawerlayout.widget.DrawerLayout + +/** + * Clips drawer rendering away from display cutouts and system-bar side insets. + * DrawerLayout otherwise lets its drawer render into that unsafe area. + */ +class SafeInsetDrawerLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : DrawerLayout(context, attrs, defStyleAttr) { + private var safeInsetLeft = 0 + private var safeInsetRight = 0 + + fun setDrawerSafeInsets(left: Int, right: Int) { + val safeLeft = left.coerceAtLeast(0) + val safeRight = right.coerceAtLeast(0) + if (safeInsetLeft == safeLeft && safeInsetRight == safeRight) { + return + } + safeInsetLeft = safeLeft + safeInsetRight = safeRight + invalidate() + } + + override fun dispatchDraw(canvas: Canvas) { + if (safeInsetLeft == 0 && safeInsetRight == 0) { + super.dispatchDraw(canvas) + return + } + val saveCount = canvas.save() + canvas.clipRect(safeInsetLeft, 0, (width - safeInsetRight).coerceAtLeast(safeInsetLeft), height) + super.dispatchDraw(canvas) + canvas.restoreToCount(saveCount) + } +} diff --git a/app/src/main/res/color/drawer_item_text_color.xml b/app/src/main/res/color/drawer_item_text_color.xml new file mode 100644 index 0000000..1758a47 --- /dev/null +++ b/app/src/main/res/color/drawer_item_text_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/drawer_item_background.xml b/app/src/main/res/drawable/drawer_item_background.xml new file mode 100644 index 0000000..735766b --- /dev/null +++ b/app/src/main/res/drawable/drawer_item_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..e84b544 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..e621f92 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/drawer_header.xml b/app/src/main/res/layout/drawer_header.xml new file mode 100644 index 0000000..ac52ddd --- /dev/null +++ b/app/src/main/res/layout/drawer_header.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_page.xml b/app/src/main/res/layout/fragment_page.xml new file mode 100644 index 0000000..af2a14c --- /dev/null +++ b/app/src/main/res/layout/fragment_page.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/menu/drawer_menu.xml b/app/src/main/res/menu/drawer_menu.xml new file mode 100644 index 0000000..ba666e6 --- /dev/null +++ b/app/src/main/res/menu/drawer_menu.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..9bd6ba2 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,11 @@ + + + #AFC6FF + #233B6A + #294475 + #10264F + #D9E2FF + #121318 + #E3E2E9 + #121318 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..054b439 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #3B5C9C + #3B5C9C + #D9E2FF + #FFFFFF + #10264F + #FBF8FF + #1B1B21 + #FBF8FF + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..0971e76 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,11 @@ + + + 216dp + 360dp + 48dp + 86dp + 86dp + 172dp + 16dp + 24dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..65d3ac8 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + Notification Log + Notification history + Open navigation + Close navigation + Settings: + View logs + Settings + Appearing + Disappearing + Text content + Image content + Edits +     Settings +     Appearing +     Disappearing +     Text content +     Image content +     Edits + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..e184533 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + +