New app theming (#1010)

* feat: start new app theming feature

* feat: add some theming colours

* refactor: start refactoring themes and colours to use dynamic attributes

* feat: adding more colours and switching over default colours to be theme based instead of hard-coded or day/night specific

* refactor: take a look at ocean light and logo colour

* feat: global search colours for light and dark ocean

* feat: more styling

* feat: adding themes to conversation activity and refactoring the base theme to apply over the top of the activity's theme so it retains noActionBar etc

* feat: add dynamic accent color

* docs: add todo for changing how accent colour is applied

* feat: update new theming to use override primary style so that the regular colorAccent attribute can be used in existing layouts

* feat: coordinating styles across layouts, fixing up pinned icons and naming for conversation list items

* refactor: re-styling layouts to match new themes and attributes. Need to figure out action mode close button

* refactor: remove @color/text and replace with ?android:textColorPrimary to override in themes

* refactor: add context theme wrapper to bottom sheet dialog that references accent color

* fix: input bar bug fix and preference activity themes

* refactor: new settings menu options

* fix: crash for PNModeActivity.kt

refactor: move ordering in seed dialog to match designs, copy changes to match new settings menu

* feat: add new appearance settings activity

* refactor: title and VM changes

* fix: correct override

* feat: add theme appearance screen UI features and start VM implementation. re-add legacy theme utils to get default for migration

* fix: compile errors and missing themes from emoji features

* refactor: remove background shape alteration and old bottom sheet styles, re-add the theme mode attr

* feat: appearance screen wired up, just need to refresh theme

* feat: add theme state recreation and fix match system settings option

* refactor: add bottom margin

* feat: explore custom preference category

* feat: add the customized session theme for CorrectedPreferenceFragment

* feat: replace AppProtectionPreferenceFragment to extend ListSummaryPreferenceFragment

* refactor: change drawable style and remove explicit dividers

* refactor: remove divider in CorrectedPreferenceFragment

* feat: add theme state check on resume, might be jarring currently

* feat: add preference divider elements for settings menu

* refactor: settings menu redesigns

* refactor: change led preference to integer and refactor TextSecurePreferences.kt

* feat: add scroll parcel to save/restore hierarchy on restart with appearance changes

* feat: add the conversations blocked contacts and refactor preference order and copy

* feat: add blocked contacts activity, basic layout and vm

* feat: add unblock DB functions and storage protocol, start working on the DB query state flow, might have to just implement recipient on modified listener

* feat: add blocked contacts and notif recipient listeners

* feat: add recipient db reader

* feat: add blocked contact interactions and fix a theming crash for notifications

* feat: introduce better equals and hashcode implementations to recipient, replace home diff util content check with hashcode-based comparison

* feat: add settings menu vectors

* fix: preview compile error

* refactor: migrating settings menu to new designs

* feat: help menu

* refactor: simplify link opening

* refactor: remove space

* feat: refactor preferences and start theming for light mode options

* refactor: fixing dark and light modes with dialogs

* refactor: popup dialogs use proper themes now

* refactor: alert dialogs and media edit fragments use attribute references

* refactor: use input bar button attribute instead color control normal in vector tint

* refactor: transparency, dialog fixes, notification fix

* refactor: attrs and styles for buttons

* fix: use prominent button color on the outline button's border

* fix: fix the trash

* refactor: remove the appearance

* refactor: avatar placeholder generation, chips and element border styles

* refactor: use colors instead of style references

* refactor: theming changes to match designs and feedback

* refactor: the titles are bold and the categories are tertiary coloured now

* fix: appearance settings match preferences, search bottom bar uses themed attributes

* refactor: increase setting button height

* Update clear all data dialog

* Update seed dialog

* refactor: more qa feedback changes

* feat: add new TLs and fa-rIR TLs

* Update notification content dialog

* Fix message requests clear all button text color

* feat: re-add screenshot observer

* refactor: make send tint accent color

* feat: add unread background differences

* fix: change unread count indicator

* build: upgrade build numbers

* Fix message requests popupmenu background color

* fix: crash from attr reference in color attribute

* build: upgrade build number

* fix: message bubbles, thumbnail backgrounds, search bar visibility with input bar, attachment buttons

* fix: tertiary text for keyboard page search view

* fix: emoji overflow colour differences

* fix: reaction pill dialog background is now correct colour

* Add style to reactions tab layout

* fix: appearance activity reverting primary color at correct time

* fix: show call privacy warning every time instead of just once

* fix: gradient background(?) and audio autoplay disable

* fix: crash in all media containing documents

* fix: reaction dialog heading fixes

* Add style to reactions tab layout

* fix: remove gradient backgrounds

* fix: adding new reaction normal text attribute to try correct the tab layout

* fix: ocean dark unread/read colours

* build; update build number

* build: update build number

* Fix ocean light theme pin color

* Update classic dark unread indicator text color

* Reduce group name text size on join community screen

* Restore tab indicator color on join community and all media screens

* Home adapter rename

* Updated the HomeViewModel to cancel old executors when new ones are added (#1)

* Updated the HomeViewModel to cancel old executors when new ones are added

* Removed a seemingly unneeded update

* Explicitly remove observers when the HomeActivity is paused (#2)

Co-authored-by: jubb <hjubb@users.noreply.github.com>
Co-authored-by: charles <charles@oxen.io>
Co-authored-by: Morgan Pretty <mpretty-cyro@users.noreply.github.com>
This commit is contained in:
ceokot 2022-10-18 17:45:16 +11:00 committed by GitHub
parent aecb40cce9
commit 38cc42a162
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 192 additions and 161 deletions

View File

@ -26,7 +26,7 @@ import network.loki.messenger.R;
public abstract class BaseActionBarActivity extends AppCompatActivity {
private static final String TAG = BaseActionBarActivity.class.getSimpleName();
private ThemeState currentThemeState;
public ThemeState currentThemeState;
private TextSecurePreferences getPreferences() {
ApplicationContext appContext = (ApplicationContext) getApplicationContext();

View File

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.home
import org.thoughtcrime.securesms.database.model.ThreadRecord
interface ConversationClickListener {
fun onConversationClick(thread: ThreadRecord)
fun onLongConversationClick(thread: ThreadRecord)
}

View File

@ -93,8 +93,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val publicKey: String
get() = textSecurePreferences.getLocalNumber()!!
private val homeAdapter: NewHomeAdapter by lazy {
NewHomeAdapter(context = this, listener = this)
private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, listener = this)
}
private val globalSearchAdapter = GlobalSearchAdapter { model ->
@ -172,23 +172,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
homeAdapter.glide = glide
binding.recyclerView.adapter = homeAdapter
binding.globalSearchRecycler.adapter = globalSearchAdapter
// Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
IP2Country.configureIfNeeded(this@HomeActivity)
homeViewModel.getObservable(this).observe(this) { newData ->
val manager = binding.recyclerView.layoutManager as LinearLayoutManager
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
val offsetTop = if(firstPos >= 0) {
manager.findViewByPosition(firstPos)?.let { view ->
manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
} ?: 0
} else 0
homeAdapter.data = newData
if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
setupMessageRequestsBanner()
updateEmptyState()
}
homeViewModel.tryUpdateChannel()
startObservingUpdates()
// Set up new conversation button
binding.newConversationButton.setOnClickListener { showNewConversation() }
// Observe blocked contacts changed events
@ -286,7 +275,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.searchToolbar.isVisible = isShown
binding.sessionToolbar.isVisible = !isShown
binding.recyclerView.isVisible = !isShown
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as NewHomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown
binding.globalSearchRecycler.isVisible = isShown
binding.newConversationButton.isVisible = !isShown
@ -335,11 +324,19 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
}
}
// If the theme hasn't changed then start observing updates again (if it does change then we
// will recreate the activity resulting in it responding to changes multiple times)
if (currentThemeState == textSecurePreferences.themeState() && !homeViewModel.getObservable(this).hasActiveObservers()) {
startObservingUpdates()
}
}
override fun onPause() {
super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false)
homeViewModel.getObservable(this).removeObservers(this)
}
override fun onDestroy() {
@ -353,6 +350,22 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// endregion
// region Updating
private fun startObservingUpdates() {
homeViewModel.getObservable(this).observe(this) { newData ->
val manager = binding.recyclerView.layoutManager as LinearLayoutManager
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
val offsetTop = if(firstPos >= 0) {
manager.findViewByPosition(firstPos)?.let { view ->
manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
} ?: 0
} else 0
homeAdapter.data = newData
if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
setupMessageRequestsBanner()
updateEmptyState()
}
}
private fun updateEmptyState() {
val threadCount = (binding.recyclerView.adapter)!!.itemCount
binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible

View File

@ -1,8 +1,115 @@
package org.thoughtcrime.securesms.home
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideRequests
class HomeAdapter(
private val context: Context,
private val listener: ConversationClickListener
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback {
companion object {
private const val HEADER = 0
private const val ITEM = 1
}
var header: View? = null
private var _data: List<ThreadRecord> = emptyList()
var data: List<ThreadRecord>
get() = _data.toList()
set(newData) {
val previousData = _data.toList()
val diff = HomeDiffUtil(previousData, newData, context)
val diffResult = DiffUtil.calculateDiff(diff)
_data = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback)
}
fun hasHeaderView(): Boolean = header != null
private val headerCount: Int
get() = if (header == null) 0 else 1
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position + headerCount, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position + headerCount, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition + headerCount, toPosition + headerCount)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position + headerCount, count, payload)
}
override fun getItemId(position: Int): Long {
if (hasHeaderView() && position == 0) return NO_ID
val offsetPosition = if (hasHeaderView()) position-1 else position
return _data[offsetPosition].threadId
}
lateinit var glide: GlideRequests
var typingThreadIDs = setOf<Long>()
set(value) {
field = value
// TODO: replace this with a diffed update or a partial change set with payloads
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
HEADER -> {
HeaderFooterViewHolder(header!!)
}
ITEM -> {
val view = ConversationView(context)
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
view.setOnLongClickListener {
view.thread?.let { listener.onLongConversationClick(it) }
true
}
ViewHolder(view)
}
else -> throw Exception("viewType $viewType isn't valid")
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
val offset = if (hasHeaderView()) position - 1 else position
val thread = data[offset]
val isTyping = typingThreadIDs.contains(thread.threadId)
holder.view.bind(thread, isTyping, glide)
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is ViewHolder) {
holder.view.recycle()
} else {
super.onViewRecycled(holder)
}
}
override fun getItemViewType(position: Int): Int =
if (hasHeaderView() && position == 0) HEADER
else ITEM
override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0
class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
interface ConversationClickListener {
fun onConversationClick(thread: ThreadRecord)
fun onLongConversationClick(thread: ThreadRecord)
}

View File

@ -7,23 +7,22 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.cash.copper.flow.observeQuery
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import java.lang.ref.WeakReference
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() {
private val executor = viewModelScope + SupervisorJob()
private var lastContext: WeakReference<Context>? = null
private var updateJobs: MutableList<Job> = mutableListOf()
private val _conversations = MutableLiveData<List<ThreadRecord>>()
val conversations: LiveData<List<ThreadRecord>> = _conversations
@ -33,25 +32,38 @@ class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): V
fun tryUpdateChannel() = listUpdateChannel.trySend(Unit)
fun getObservable(context: Context): LiveData<List<ThreadRecord>> {
executor.launch(Dispatchers.IO) {
context.contentResolver
.observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI)
.onEach { listUpdateChannel.trySend(Unit) }
.collect()
}
executor.launch(Dispatchers.IO) {
for (update in listUpdateChannel) {
threadDb.approvedConversationList.use { openCursor ->
val reader = threadDb.readerFor(openCursor)
val threads = mutableListOf<ThreadRecord>()
while (true) {
threads += reader.next ?: break
}
withContext(Dispatchers.Main) {
_conversations.value = threads
// If the context has changed (eg. the activity gets recreated) then
// we need to cancel the old executors and recreate them to prevent
// the app from triggering extra updates when data changes
if (context != lastContext?.get()) {
lastContext = WeakReference(context)
updateJobs.forEach { it.cancel() }
updateJobs.clear()
updateJobs.add(
executor.launch(Dispatchers.IO) {
context.contentResolver
.observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI)
.onEach { listUpdateChannel.trySend(Unit) }
.collect()
}
)
updateJobs.add(
executor.launch(Dispatchers.IO) {
for (update in listUpdateChannel) {
threadDb.approvedConversationList.use { openCursor ->
val reader = threadDb.readerFor(openCursor)
val threads = mutableListOf<ThreadRecord>()
while (true) {
threads += reader.next ?: break
}
withContext(Dispatchers.Main) {
_conversations.value = threads
}
}
}
}
}
)
}
return conversations
}

View File

@ -1,114 +0,0 @@
package org.thoughtcrime.securesms.home
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideRequests
class NewHomeAdapter(private val context: Context, private val listener: ConversationClickListener):
RecyclerView.Adapter<RecyclerView.ViewHolder>(),
ListUpdateCallback {
companion object {
private const val HEADER = 0
private const val ITEM = 1
}
var header: View? = null
private var _data: List<ThreadRecord> = emptyList()
var data: List<ThreadRecord>
get() = _data.toList()
set(newData) {
val previousData = _data.toList()
val diff = HomeDiffUtil(previousData, newData, context)
val diffResult = DiffUtil.calculateDiff(diff)
_data = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback)
}
fun hasHeaderView(): Boolean = header != null
private val headerCount: Int
get() = if (header == null) 0 else 1
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position + headerCount, count)
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position + headerCount, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition + headerCount, toPosition + headerCount)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position + headerCount, count, payload)
}
override fun getItemId(position: Int): Long {
if (hasHeaderView() && position == 0) return NO_ID
val offsetPosition = if (hasHeaderView()) position-1 else position
return _data[offsetPosition].threadId
}
lateinit var glide: GlideRequests
var typingThreadIDs = setOf<Long>()
set(value) {
field = value
// TODO: replace this with a diffed update or a partial change set with payloads
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
HEADER -> {
HeaderFooterViewHolder(header!!)
}
ITEM -> {
val view = ConversationView(context)
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
view.setOnLongClickListener {
view.thread?.let { listener.onLongConversationClick(it) }
true
}
ViewHolder(view)
}
else -> throw Exception("viewType $viewType isn't valid")
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ViewHolder) {
val offset = if (hasHeaderView()) position - 1 else position
val thread = data[offset]
val isTyping = typingThreadIDs.contains(thread.threadId)
holder.view.bind(thread, isTyping, glide)
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is ViewHolder) {
holder.view.recycle()
} else {
super.onViewRecycled(holder)
}
}
override fun getItemViewType(position: Int): Int =
if (hasHeaderView() && position == 0) HEADER
else ITEM
override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0
class ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}

View File

@ -7,7 +7,7 @@
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:ellipsize="end"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:chipStrokeWidth="1dp"
app:chipStrokeColor="?elementBorderColor"
app:chipBackgroundColor="?dialog_background_color"

View File

@ -9,7 +9,7 @@
android:paddingBottom="@dimen/react_with_any_emoji_bottom_sheet_dialog_fragment_tabs_height">
<com.google.android.material.tabs.TabLayout
style="@style/Widget.Session.TabLayout"
style="@style/Widget.Session.TabLayout.Reactions"
android:id="@+id/emoji_tabs"
android:layout_width="match_parent"
android:layout_height="@dimen/react_with_any_emoji_bottom_sheet_dialog_fragment_tabs_height"

View File

@ -70,6 +70,10 @@
<item name="tabTextAppearance">@style/TextAppearance.Session.Tab</item>
</style>
<style name="Widget.Session.TabLayout.Reactions">
<item name="tabIndicatorColor">@color/transparent</item>
</style>
<style name="TextAppearance.Session.Tab" parent="TextAppearance.Design.Tab">
<item name="android:textSize">@dimen/medium_font_size</item>
<item name="textAllCaps">false</item>

View File

@ -354,7 +354,8 @@
<item name="conversation_unread_background_color">@color/classic_dark_2</item>
<item name="conversation_pinned_icon_color">@color/classic_dark_4</item>
<item name="unreadIndicatorBackgroundColor">@color/classic_dark_3</item>
<item name="unreadIndicatorTextColor">@color/classic_dark_6</item>
<item name="unreadIndicatorTextColor">@color/classic_dark_0</item>
<!-- New conversation button -->
<item name="conversation_color_non_main">@color/classic_dark_2</item>
<item name="conversation_shadow_non_main">@color/transparent_black_30</item>
@ -631,7 +632,7 @@
<item name="conversation_unread_count_indicator_background">?colorAccent</item>
<item name="conversation_pinned_background_color">?colorCellBackground</item>
<item name="conversation_unread_background_color">@color/ocean_light_5</item>
<item name="conversation_pinned_icon_color">?colorPrimary</item>
<item name="conversation_pinned_icon_color">?android:textColorPrimary</item>
</style>
</resources>