Scaffold responsive Android notification log shell

This commit is contained in:
ajp_anton
2026-07-23 07:37:07 +00:00
parent 591a3b993c
commit 022fb5220f
25 changed files with 1045 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.NotificationLog">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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
}
}
@@ -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) }
}
}
}
@@ -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)
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/on_primary_container" android:state_checked="true" />
<item android:color="@color/on_surface" />
</selector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true">
<shape>
<solid android:color="@color/primary_container" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="@android:color/transparent" />
</shape>
</item>
</selector>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#D9E2FF"
android:pathData="M8,7h32a4,4 0,0 1,4 4v26a4,4 0,0 1,-4 4H8a4,4 0,0 1,-4 -4V11a4,4 0,0 1,4 -4z" />
<path
android:fillColor="#24345E"
android:pathData="M13,15h22v3H13zM13,23h22v3H13zM13,31h14v3H13z" />
</vector>
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<se.ajpanton.notificationlog.SafeInsetDrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/window_background"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view_permanent"
android:layout_width="@dimen/navigation_drawer_width"
android:layout_height="match_parent"
android:background="@color/surface"
android:fitsSystemWindows="true"
android:visibility="gone"
app:headerLayout="@layout/drawer_header"
app:itemBackground="@drawable/drawer_item_background"
app:itemTextColor="@color/drawer_item_text_color"
app:menu="@menu/drawer_menu" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/primary"
android:elevation="0dp"
android:theme="@style/ThemeOverlay.NotificationLog.Toolbar"
app:navigationIconTint="@color/on_primary"
app:titleTextColor="@color/on_primary" />
<FrameLayout
android:id="@+id/content_frame"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view_drawer"
android:layout_width="@dimen/navigation_drawer_width"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="@color/surface"
android:fitsSystemWindows="true"
app:headerLayout="@layout/drawer_header"
app:itemBackground="@drawable/drawer_item_background"
app:itemTextColor="@color/drawer_item_text_color"
app:menu="@menu/drawer_menu" />
</se.ajpanton.notificationlog.SafeInsetDrawerLayout>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/drawer_header_height"
android:background="@color/primary"
android:gravity="bottom"
android:orientation="vertical"
android:padding="@dimen/drawer_header_padding">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="?attr/textAppearanceHeadline6"
android:textColor="@color/on_primary"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/drawer_subtitle"
android:textAppearance="?attr/textAppearanceCaption"
android:textColor="@color/on_primary" />
</LinearLayout>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/page_padding">
<TextView
android:id="@+id/page_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadline5"
android:textColor="?attr/colorOnBackground" />
</LinearLayout>
</ScrollView>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:checkableBehavior="single">
<item
android:id="@+id/nav_view_logs"
android:title="@string/page_view_logs" />
</group>
<item
android:id="@+id/nav_settings_header"
android:checkable="false"
android:enabled="false"
android:title="@string/navigation_settings_header" />
<group android:checkableBehavior="single">
<item
android:id="@+id/nav_settings"
android:title="@string/navigation_indented_settings" />
<item
android:id="@+id/nav_appearing"
android:title="@string/navigation_indented_appearing" />
<item
android:id="@+id/nav_disappearing"
android:title="@string/navigation_indented_disappearing" />
<item
android:id="@+id/nav_text_content"
android:title="@string/navigation_indented_text_content" />
<item
android:id="@+id/nav_image_content"
android:title="@string/navigation_indented_image_content" />
<item
android:id="@+id/nav_edits"
android:title="@string/navigation_indented_edits" />
</group>
</menu>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#AFC6FF</color>
<color name="system_status_bar">#233B6A</color>
<color name="primary_container">#294475</color>
<color name="on_primary">#10264F</color>
<color name="on_primary_container">#D9E2FF</color>
<color name="surface">#121318</color>
<color name="on_surface">#E3E2E9</color>
<color name="window_background">#121318</color>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#3B5C9C</color>
<color name="system_status_bar">#3B5C9C</color>
<color name="primary_container">#D9E2FF</color>
<color name="on_primary">#FFFFFF</color>
<color name="on_primary_container">#10264F</color>
<color name="surface">#FBF8FF</color>
<color name="on_surface">#1B1B21</color>
<color name="window_background">#FBF8FF</color>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="navigation_drawer_width">216dp</dimen>
<dimen name="folded_portrait_width">360dp</dimen>
<dimen name="navigation_menu_item_height">48dp</dimen>
<dimen name="drawer_header_height">86dp</dimen>
<dimen name="drawer_header_min_height">86dp</dimen>
<dimen name="drawer_header_max_height">172dp</dimen>
<dimen name="drawer_header_padding">16dp</dimen>
<dimen name="page_padding">24dp</dimen>
</resources>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Notification Log</string>
<string name="drawer_subtitle">Notification history</string>
<string name="navigation_open">Open navigation</string>
<string name="navigation_close">Close navigation</string>
<string name="navigation_settings_header">Settings:</string>
<string name="page_view_logs">View logs</string>
<string name="page_settings">Settings</string>
<string name="page_appearing">Appearing</string>
<string name="page_disappearing">Disappearing</string>
<string name="page_text_content">Text content</string>
<string name="page_image_content">Image content</string>
<string name="page_edits">Edits</string>
<string name="navigation_indented_settings">&#160;&#160;&#160;&#160;Settings</string>
<string name="navigation_indented_appearing">&#160;&#160;&#160;&#160;Appearing</string>
<string name="navigation_indented_disappearing">&#160;&#160;&#160;&#160;Disappearing</string>
<string name="navigation_indented_text_content">&#160;&#160;&#160;&#160;Text content</string>
<string name="navigation_indented_image_content">&#160;&#160;&#160;&#160;Image content</string>
<string name="navigation_indented_edits">&#160;&#160;&#160;&#160;Edits</string>
</resources>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NotificationLog" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/on_primary</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/on_surface</item>
<item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@color/system_status_bar</item>
<item name="android:navigationBarColor">@color/window_background</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
<style name="ThemeOverlay.NotificationLog.Toolbar" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
</resources>