Improve conversation view

This commit is contained in:
Christian Basler 2018-03-18 07:00:21 +01:00
parent 8004865e01
commit 46e5bb7ece
12 changed files with 559 additions and 14 deletions

View File

@ -0,0 +1,188 @@
/*
* Copyright 2016 Christian Basler
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ch.dissem.apps.abit
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.annotation.IdRes
import android.support.v4.app.Fragment
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.view.*
import android.widget.ImageView
import android.widget.TextView
import ch.dissem.apps.abit.adapter.ConversationAdapter
import ch.dissem.apps.abit.service.Singleton
import ch.dissem.apps.abit.util.Assets
import ch.dissem.apps.abit.util.Drawables
import ch.dissem.apps.abit.util.Strings.prepareMessageExtract
import ch.dissem.bitmessage.entity.Conversation
import ch.dissem.bitmessage.entity.Plaintext
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import kotlinx.android.synthetic.main.fragment_conversation_detail.*
/**
* A fragment representing a single Message detail screen.
* This fragment is either contained in a [MainActivity]
* in two-pane mode (on tablets) or a [MessageDetailActivity]
* on handsets.
*/
class ConversationDetailFragment : Fragment() {
/**
* The content this fragment is presenting.
*/
private var item: Conversation? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let { arguments ->
if (arguments.containsKey(ARG_ITEM)) {
// Load the dummy content specified by the fragment
// arguments. In a real-world scenario, use a Loader
// to load content from a content provider.
item = arguments.getSerializable(ARG_ITEM) as Conversation
}
}
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View =
inflater.inflate(R.layout.fragment_conversation_detail, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val ctx = activity ?: throw IllegalStateException("Fragment is not attached to an activity")
// Show the dummy content as text in a TextView.
item?.let { item ->
subject.text = item.subject
avatar.setImageDrawable(MultiIdenticon(item.participants))
messages.adapter = ConversationAdapter(ctx, this@ConversationDetailFragment, item)
messages.layoutManager = LinearLayoutManager(activity)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.conversation, menu)
activity?.let { activity ->
Drawables.addIcon(activity, menu, R.id.delete, GoogleMaterial.Icon.gmd_delete)
Drawables.addIcon(activity, menu, R.id.archive, GoogleMaterial.Icon.gmd_archive)
}
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
val messageRepo = Singleton.getMessageRepository(
context ?: throw IllegalStateException("No context available")
)
item?.let { item ->
when (menuItem.itemId) {
R.id.delete -> {
item.messages.forEach {
Singleton.labeler.delete(it)
messageRepo.remove(it)
}
MainActivity.apply { updateUnread() }
activity?.onBackPressed()
return true
}
R.id.archive -> {
item.messages.forEach {
Singleton.labeler.archive(it)
messageRepo.save(it)
}
MainActivity.apply { updateUnread() }
return true
}
else -> return false
}
}
return false
}
private class RelatedMessageAdapter internal constructor(
private val ctx: Context,
private val messages: List<Plaintext>
) : RecyclerView.Adapter<RelatedMessageAdapter.ViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RelatedMessageAdapter.ViewHolder {
val context = parent.context
val inflater = LayoutInflater.from(context)
// Inflate the custom layout
val contactView = inflater.inflate(R.layout.item_message_minimized, parent, false)
// Return a new holder instance
return ViewHolder(contactView)
}
// Involves populating data into the item through holder
override fun onBindViewHolder(viewHolder: RelatedMessageAdapter.ViewHolder, position: Int) {
// Get the data model based on position
val message = messages[position]
viewHolder.avatar.setImageDrawable(Identicon(message.from))
viewHolder.status.setImageResource(Assets.getStatusDrawable(message.status))
viewHolder.sender.text = message.from.toString()
viewHolder.extract.text = prepareMessageExtract(message.text)
viewHolder.item = message
}
// Returns the total count of items in the list
override fun getItemCount() = messages.size
internal inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
internal val avatar = itemView.findViewById<ImageView>(R.id.avatar)
internal val status = itemView.findViewById<ImageView>(R.id.status)
internal val sender = itemView.findViewById<TextView>(R.id.sender)
internal val extract = itemView.findViewById<TextView>(R.id.text)
internal var item: Plaintext? = null
init {
itemView.setOnClickListener {
if (ctx is MainActivity) {
item?.let { ctx.onItemSelected(it) }
} else {
val detailIntent = Intent(ctx, MessageDetailActivity::class.java)
detailIntent.putExtra(MessageDetailFragment.ARG_ITEM, item)
ctx.startActivity(detailIntent)
}
}
}
}
}
companion object {
/**
* The fragment argument representing the item ID that this fragment
* represents.
*/
const val ARG_ITEM = "item"
}
}

View File

@ -203,9 +203,7 @@ class ConversationListFragment : Fragment(), ListHolder<Label> {
val position = recycler_view.getChildAdapterPosition(v) val position = recycler_view.getChildAdapterPosition(v)
adapter.setSelectedPosition(position) adapter.setSelectedPosition(position)
if (position != RecyclerView.NO_POSITION) { if (position != RecyclerView.NO_POSITION) {
adapter.getItem(position).messages.firstOrNull()?.let { MainActivity.apply { onItemSelected(adapter.getItem(position)) }
MainActivity.apply { onItemSelected(it) }
}
} }
} }
} }

View File

@ -124,7 +124,7 @@ class MultiIdenticon(input: List<BitmessageAddress>, @ColorInt val backgroundCol
color = backgroundColor color = backgroundColor
} }
val identicons = input.map { Identicon(it) }.take(4) private val identicons = input.map { Identicon(it) }.take(4)
override fun draw(canvas: Canvas) { override fun draw(canvas: Canvas) {
val width = canvas.width.toFloat() val width = canvas.width.toFloat()
@ -132,7 +132,7 @@ class MultiIdenticon(input: List<BitmessageAddress>, @ColorInt val backgroundCol
when (identicons.size) { when (identicons.size) {
0 -> canvas.drawCircle(width / 2, height / 2, width / 2, paint) 0 -> canvas.drawCircle(width / 2, height / 2, width / 2, paint)
1 -> identicons.first().draw(canvas) 1 -> identicons.first().draw(canvas, 0f, 0f, width, height)
2 -> { 2 -> {
canvas.drawCircle(width / 2, height / 2, width / 2, paint) canvas.drawCircle(width / 2, height / 2, width / 2, paint)
val w = width / 2 val w = width / 2

View File

@ -32,9 +32,13 @@ import ch.dissem.apps.abit.repository.AndroidLabelRepository.Companion.LABEL_ARC
import ch.dissem.apps.abit.service.Singleton import ch.dissem.apps.abit.service.Singleton
import ch.dissem.apps.abit.service.Singleton.currentLabel import ch.dissem.apps.abit.service.Singleton.currentLabel
import ch.dissem.apps.abit.synchronization.SyncAdapter import ch.dissem.apps.abit.synchronization.SyncAdapter
import ch.dissem.apps.abit.util.* import ch.dissem.apps.abit.util.NetworkUtils
import ch.dissem.apps.abit.util.Preferences
import ch.dissem.apps.abit.util.getColor
import ch.dissem.apps.abit.util.getIcon
import ch.dissem.bitmessage.BitmessageContext import ch.dissem.bitmessage.BitmessageContext
import ch.dissem.bitmessage.entity.BitmessageAddress import ch.dissem.bitmessage.entity.BitmessageAddress
import ch.dissem.bitmessage.entity.Conversation
import ch.dissem.bitmessage.entity.Plaintext import ch.dissem.bitmessage.entity.Plaintext
import ch.dissem.bitmessage.entity.valueobject.Label import ch.dissem.bitmessage.entity.valueobject.Label
import com.github.amlcurran.showcaseview.ShowcaseView import com.github.amlcurran.showcaseview.ShowcaseView
@ -458,6 +462,13 @@ class MainActivity : AppCompatActivity(), ListSelectionListener<Serializable> {
// adding or replacing the detail fragment using a // adding or replacing the detail fragment using a
// fragment transaction. // fragment transaction.
val fragment = when (item) { val fragment = when (item) {
is Conversation -> {
ConversationDetailFragment().apply {
arguments = Bundle().apply {
putSerializable(ConversationDetailFragment.ARG_ITEM, item)
}
}
}
is Plaintext -> { is Plaintext -> {
if (item.labels.any { it.type == Label.Type.DRAFT }) { if (item.labels.any { it.type == Label.Type.DRAFT }) {
ComposeMessageFragment().apply { ComposeMessageFragment().apply {
@ -489,6 +500,11 @@ class MainActivity : AppCompatActivity(), ListSelectionListener<Serializable> {
// In single-pane mode, simply start the detail activity // In single-pane mode, simply start the detail activity
// for the selected item ID. // for the selected item ID.
val detailIntent = when (item) { val detailIntent = when (item) {
is Conversation -> {
Intent(this, MessageDetailActivity::class.java).apply {
putExtra(ConversationDetailFragment.ARG_ITEM, item)
}
}
is Plaintext -> { is Plaintext -> {
if (item.labels.any { it.type == Label.Type.DRAFT }) { if (item.labels.any { it.type == Label.Type.DRAFT }) {
Intent(this, ComposeMessageActivity::class.java).apply { Intent(this, ComposeMessageActivity::class.java).apply {

View File

@ -4,6 +4,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.support.v4.app.NavUtils import android.support.v4.app.NavUtils
import android.view.MenuItem import android.view.MenuItem
import ch.dissem.bitmessage.entity.Conversation
/** /**
@ -33,13 +34,17 @@ class MessageDetailActivity : DetailActivity() {
// Create the detail fragment and add it to the activity // Create the detail fragment and add it to the activity
// using a fragment transaction. // using a fragment transaction.
val arguments = Bundle() val arguments = Bundle()
arguments.putSerializable(MessageDetailFragment.ARG_ITEM, val item = intent.getSerializableExtra(MessageDetailFragment.ARG_ITEM)
intent.getSerializableExtra(MessageDetailFragment.ARG_ITEM)) arguments.putSerializable(MessageDetailFragment.ARG_ITEM, item)
val fragment = MessageDetailFragment() val fragment = if (item is Conversation) {
ConversationDetailFragment()
} else {
MessageDetailFragment()
}
fragment.arguments = arguments fragment.arguments = arguments
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.add(R.id.content, fragment) .add(R.id.content, fragment)
.commit() .commit()
} }
} }

View File

@ -0,0 +1,152 @@
package ch.dissem.apps.abit.adapter
import android.content.Context
import android.support.v4.app.Fragment
import android.support.v7.widget.GridLayoutManager
import android.support.v7.widget.PopupMenu
import android.support.v7.widget.RecyclerView
import android.text.util.Linkify
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import ch.dissem.apps.abit.*
import ch.dissem.apps.abit.service.Singleton
import ch.dissem.apps.abit.util.Assets
import ch.dissem.apps.abit.util.Constants
import ch.dissem.bitmessage.entity.Conversation
import ch.dissem.bitmessage.entity.Plaintext
import ch.dissem.bitmessage.entity.valueobject.Label
import ch.dissem.bitmessage.ports.MessageRepository
class ConversationAdapter internal constructor(
ctx: Context,
private val parent: Fragment,
private val conversation: Conversation
) : RecyclerView.Adapter<ConversationAdapter.ViewHolder>() {
private val messageRepo = Singleton.getMessageRepository(ctx)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ConversationAdapter.ViewHolder {
val context = parent.context
val inflater = LayoutInflater.from(context)
// Inflate the custom layout
val messageView = inflater.inflate(R.layout.item_message_detail, parent, false)
// Return a new holder instance
return ViewHolder(messageView, this.parent, messageRepo)
}
// Involves populating data into the item through holder
override fun onBindViewHolder(viewHolder: ConversationAdapter.ViewHolder, position: Int) {
// Get the data model based on position
val message = conversation.messages[position]
viewHolder.apply {
item = message
avatar.setImageDrawable(Identicon(message.from))
sender.text = message.from.toString()
val senderClickListener: (View) -> Unit = {
MainActivity.apply {
onItemSelected(message.from)
}
}
avatar.setOnClickListener(senderClickListener)
sender.setOnClickListener(senderClickListener)
recipient.text = message.to.toString()
status.setImageResource(Assets.getStatusDrawable(message.status))
text.text = message.text
Linkify.addLinks(text, Linkify.WEB_URLS)
Linkify.addLinks(text,
Constants.BITMESSAGE_ADDRESS_PATTERN,
Constants.BITMESSAGE_URL_SCHEMA, null,
Linkify.TransformFilter { match, _ -> match.group() }
)
labelAdapter.labels = message.labels.toList()
// FIXME: I think that's not quite correct
if (message.isUnread()) {
Singleton.labeler.markAsRead(message)
messageRepo.save(message)
MainActivity.apply { updateUnread() }
}
}
}
override fun getItemCount() = conversation.messages.size
class ViewHolder(
itemView: View,
parent: Fragment,
messageRepo: MessageRepository
) : RecyclerView.ViewHolder(itemView) {
var item: Plaintext? = null
val avatar = itemView.findViewById<ImageView>(R.id.avatar)!!
val sender = itemView.findViewById<TextView>(R.id.sender)!!
val recipient = itemView.findViewById<TextView>(R.id.recipient)!!
val status = itemView.findViewById<ImageView>(R.id.status)!!
val menu = itemView.findViewById<ImageView>(R.id.menu)!!.also { view ->
view.setOnClickListener {
val popup = PopupMenu(itemView.context, view)
popup.menuInflater.inflate(R.menu.message, popup.menu)
popup.setOnMenuItemClickListener {
item?.let { item ->
when (it.itemId) {
R.id.reply -> {
ComposeMessageActivity.launchReplyTo(parent, item)
true
}
R.id.delete -> {
if (MessageDetailFragment.isInTrash(item)) {
Singleton.labeler.delete(item)
messageRepo.remove(item)
} else {
Singleton.labeler.delete(item)
messageRepo.save(item)
}
MainActivity.apply {
updateUnread()
onBackPressed()
}
true
}
R.id.mark_unread -> {
Singleton.labeler.markAsUnread(item)
messageRepo.save(item)
MainActivity.apply { updateUnread() }
true
}
R.id.archive -> {
Singleton.labeler.archive(item)
messageRepo.save(item)
MainActivity.apply { updateUnread() }
true
}
else -> false
}
} ?: false
}
popup.show()
}
}
val text = itemView.findViewById<TextView>(R.id.text)!!.apply {
linksClickable = true
setTextIsSelectable(true)
}
val labelAdapter = LabelAdapter(itemView.context, emptySet<Label>())
val labels = itemView.findViewById<RecyclerView>(R.id.labels)!!.apply {
adapter = labelAdapter
layoutManager = GridLayoutManager(itemView.context, 2)
}
}
}

View File

@ -46,15 +46,14 @@ class LabelAdapter internal constructor(private val ctx: Context, labels: Collec
override fun getItemCount() = labels.size override fun getItemCount() = labels.size
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val background = itemView
var icon = itemView.findViewById<IconicsImageView>(R.id.icon)!! var icon = itemView.findViewById<IconicsImageView>(R.id.icon)!!
var label = itemView.findViewById<TextView>(R.id.label)!! var label = itemView.findViewById<TextView>(R.id.label)!!
fun setBackground(@ColorInt color: Int) { fun setBackground(@ColorInt color: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
background.backgroundTintList = ColorStateList.valueOf(color) itemView.backgroundTintList = ColorStateList.valueOf(color)
} else { } else {
background.backgroundColor = color itemView.backgroundColor = color
} }
} }
} }

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24">
<path
android:fillColor="#000"
android:pathData="M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z" />
</vector>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:focusableInTouchMode="true"
android:orientation="vertical"
android:paddingBottom="64dp">
<TextView
android:id="@+id/subject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_toStartOf="@+id/avatar"
android:elegantTextHeight="false"
android:enabled="false"
android:gravity="center_vertical"
android:padding="16dp"
android:textAppearance="?android:attr/textAppearanceLarge"
tools:ignore="UnusedAttribute"
tools:text="Subject" />
<ImageView
android:id="@+id/avatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:layout_margin="10dp"
android:src="@color/colorAccent"
tools:ignore="ContentDescription" />
<View
android:id="@+id/divider"
android:layout_width="fill_parent"
android:layout_height="2dip"
android:layout_below="@id/subject"
android:background="@color/divider" />
<android.support.v7.widget.RecyclerView
android:id="@+id/messages"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/divider"
android:animateLayoutChanges="true"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="16dp"/>
</RelativeLayout>
</ScrollView>

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/avatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_marginTop="8dp"
android:src="@color/colorAccent"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/sender"
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_alignTop="@+id/avatar"
android:layout_toEndOf="@+id/avatar"
android:layout_toStartOf="@+id/status"
android:gravity="center_vertical"
android:paddingEnd="0dp"
android:paddingStart="8dp"
android:textStyle="bold"
tools:text="Sender" />
<TextView
android:id="@+id/recipient"
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_alignBottom="@+id/avatar"
android:layout_toEndOf="@+id/avatar"
android:layout_toStartOf="@+id/status"
android:gravity="center_vertical"
android:paddingEnd="0dp"
android:paddingStart="8dp"
tools:text="Recipient" />
<ImageView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/menu"
android:paddingBottom="8dp"
android:paddingTop="8dp"
android:tint="@color/colorAccent"
tools:ignore="ContentDescription"
tools:src="@drawable/ic_notification_proof_of_work" />
<ImageView
android:id="@+id/menu"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:contentDescription="@string/context_menu"
android:padding="8dp"
android:src="@drawable/ic_menu" />
</RelativeLayout>
<LinearLayout
android:id="@+id/body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginTop="16dp"
android:textIsSelectable="true"
tools:text="Message Body" />
<android.support.v7.widget.RecyclerView
android:id="@+id/labels"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp" />
</LinearLayout>
<View
android:id="@+id/divider"
android:layout_width="fill_parent"
android:layout_height="2dip"
android:background="@color/divider" />
</LinearLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/delete"
app:showAsAction="ifRoom"
android:icon="@drawable/ic_action_delete"
android:title="@string/delete"/>
<item
android:id="@+id/archive"
app:showAsAction="ifRoom"
android:icon="@drawable/ic_action_archive"
android:title="@string/archive"/>
</menu>

View File

@ -136,4 +136,5 @@ As an alternative you could configure a trusted node in the settings, but as of
<string name="broadcasts">Broadcasts</string> <string name="broadcasts">Broadcasts</string>
<string name="encoding_simple">simple</string> <string name="encoding_simple">simple</string>
<string name="encoding_extended">extended</string> <string name="encoding_extended">extended</string>
<string name="context_menu">actions</string>
</resources> </resources>