Compare commits

...

8 Commits

Author SHA1 Message Date
Lonami Exo 07d0e5e505 Wrap freeDialogs call inside a finally block
For instance, LocalDateTime.parse could fail,
and then the dialogs would be leaked.
2022-10-23 20:27:15 +02:00
Lonami Exo 442b019134 Load more dialog data from the API 2022-10-23 20:25:45 +02:00
Lonami Exo 51ef704cf7 Improve dialog screen by displaying more info 2022-10-23 20:01:24 +02:00
Lonami Exo 17d7805654 Bump minSdk to 26
This lets us use LocalDate.
The minSdk can be turned back if needed but for now this simplifies things.
2022-10-23 19:06:18 +02:00
Lonami Exo 6c652e3e1b Fix references to DialogRepository and other imports 2022-10-21 20:34:25 +02:00
Lonami Exo 999c59e9ec Create a separate package for UI screens and state 2022-10-21 20:31:29 +02:00
Lonami Exo c627db973c Rename model package to models for consistency 2022-10-21 20:27:33 +02:00
Lonami Exo fc7dc68b62 Rename Source to Repository
This is more in line with the repository
google-developer-training/basic-android-kotlin-compose-training-mars-photos
which contains good practices.
2022-10-21 20:24:29 +02:00
26 changed files with 418 additions and 145 deletions

View File

@ -10,7 +10,7 @@ android {
defaultConfig {
applicationId "dev.lonami.talaria"
minSdk 21
minSdk 26
targetSdk 33
versionCode 1
versionName "0.1.0"

View File

@ -13,9 +13,9 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.ui.ChatScreen
import dev.lonami.talaria.ui.DialogScreen
import dev.lonami.talaria.ui.LoginScreen
import dev.lonami.talaria.ui.screens.ChatScreen
import dev.lonami.talaria.ui.screens.DialogScreen
import dev.lonami.talaria.ui.screens.LoginScreen
enum class TalariaScreen(@StringRes val title: Int) {
Login(title = R.string.app_name),

View File

@ -14,6 +14,11 @@ object Native {
external fun dialogCount(dialogsPtr: Long): Int
external fun dialogPacked(dialogsPtr: Long, index: Int): String
external fun dialogTitle(dialogsPtr: Long, index: Int): String
external fun dialogSender(dialogsPtr: Long, index: Int): String
external fun dialogText(dialogsPtr: Long, index: Int): String
external fun dialogTime(dialogsPtr: Long, index: Int): String
external fun dialogAck(dialogsPtr: Long, index: Int): Int
external fun dialogPin(dialogsPtr: Long, index: Int): Int
external fun freeDialogs(dialogsPtr: Long)
external fun sendMessage(packed: String, text: String)
}

View File

@ -0,0 +1,89 @@
package dev.lonami.talaria.data
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.models.Dialog
import dev.lonami.talaria.models.MessageAck
import dev.lonami.talaria.models.MessagePreview
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
interface DialogRepository {
fun loadDialogs(): List<Dialog>;
}
class NativeDialogRepository : DialogRepository {
override fun loadDialogs(): List<Dialog> {
val dialogs = mutableListOf<Dialog>()
val dialogPtr = Native.getDialogs()
try {
val dialogCount = Native.dialogCount(dialogPtr)
for (i in 0 until dialogCount) {
dialogs.add(
Dialog(
id = Native.dialogPacked(dialogPtr, i),
title = Native.dialogTitle(dialogPtr, i),
lastMessage = MessagePreview(
sender = Native.dialogSender(dialogPtr, i),
text = Native.dialogText(dialogPtr, i),
date = LocalDateTime.parse(
Native.dialogTime(dialogPtr, i),
DateTimeFormatter.ISO_OFFSET_DATE_TIME
),
ack = when (Native.dialogAck(dialogPtr, i)) {
0 -> MessageAck.RECEIVED
1 -> MessageAck.SENT
2 -> MessageAck.SEEN
else -> MessageAck.RECEIVED
}
),
pinned = Native.dialogPin(dialogPtr, i) != 0
)
)
}
} finally {
Native.freeDialogs(dialogPtr)
}
return dialogs
}
}
class MockDialogRepository : DialogRepository {
override fun loadDialogs(): List<Dialog> {
val dialogs = mutableListOf<Dialog>()
for (i in 0 until 10) {
dialogs.add(
Dialog(
id = "$i",
title = "Sample Dialog $i",
lastMessage = if (i % 4 == 3) {
null
} else {
MessagePreview(
sender = if (i % 2 == 0) {
"Sender A"
} else {
"Sender B"
},
text = if (i % 3 == 2) {
"Very Long Sample Message $i, with a Lot of Text, which makes it hard to Preview"
} else {
"Sample Message $i"
},
date = LocalDateTime.now(),
ack = when (i % 3) {
0 -> MessageAck.RECEIVED
1 -> MessageAck.SENT
2 -> MessageAck.SEEN
else -> throw RuntimeException()
}
)
},
pinned = i < 4
)
)
}
return dialogs
}
}

View File

@ -1,39 +0,0 @@
package dev.lonami.talaria.data
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.model.Dialog
interface DialogSource {
fun loadDialogs(): List<Dialog>;
}
class NativeDialogSource : DialogSource {
override fun loadDialogs(): List<Dialog> {
val dialogs = mutableListOf<Dialog>()
val dialogPtr = Native.getDialogs()
val dialogCount = Native.dialogCount(dialogPtr)
for (i in 0 until dialogCount) {
dialogs.add(
Dialog(
Native.dialogTitle(dialogPtr, i),
Native.dialogPacked(dialogPtr, i),
false
)
)
}
Native.freeDialogs(dialogPtr)
return dialogs
}
}
class MockDialogSource : DialogSource {
override fun loadDialogs(): List<Dialog> {
val dialogs = mutableListOf<Dialog>()
for (i in 0 until 10) {
dialogs.add(Dialog("Sample Dialog $i", "Sample Message $i", i == 0))
}
return dialogs
}
}

View File

@ -1,8 +1,8 @@
package dev.lonami.talaria.data
import dev.lonami.talaria.model.Message
import dev.lonami.talaria.models.Message
object MessageSource {
object MessageRepository {
fun loadMessages(): List<Message> {
return generateSequence {
Message("Alice", "Testing")

View File

@ -1,3 +0,0 @@
package dev.lonami.talaria.model
data class Dialog(val title: String, val lastMessage: String, val pinned: Boolean)

View File

@ -0,0 +1,8 @@
package dev.lonami.talaria.models
data class Dialog(
val id: String,
val title: String,
val lastMessage: MessagePreview?,
val pinned: Boolean
)

View File

@ -1,3 +1,3 @@
package dev.lonami.talaria.model
package dev.lonami.talaria.models
data class Message(val sender: String, val text: String)

View File

@ -0,0 +1,7 @@
package dev.lonami.talaria.models
enum class MessageAck {
RECEIVED,
SENT,
SEEN,
}

View File

@ -0,0 +1,10 @@
package dev.lonami.talaria.models
import java.time.LocalDateTime
data class MessagePreview(
val sender: String,
val text: String,
val date: LocalDateTime,
val ack: MessageAck
)

View File

@ -1,77 +0,0 @@
package dev.lonami.talaria.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Card
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.lonami.talaria.R
import dev.lonami.talaria.data.MockDialogSource
import dev.lonami.talaria.model.Dialog
import dev.lonami.talaria.ui.theme.TalariaTheme
@Composable
fun DialogCard(dialog: Dialog, onDialogSelected: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp, 16.dp)
.clickable(onClick = onDialogSelected)
) {
Row {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.profile_photo),
)
Column(modifier = Modifier.weight(1.0f)) {
Text(dialog.title, fontWeight = FontWeight.Bold)
Text(dialog.lastMessage)
}
Switch(dialog.pinned, enabled = false, onCheckedChange = null)
}
}
}
@Composable
fun DialogList(dialogs: List<Dialog>, onDialogSelected: (String) -> Unit) {
LazyColumn {
items(dialogs.size) {
DialogCard(dialogs[it], onDialogSelected = {
onDialogSelected(dialogs[it].lastMessage)
})
}
}
}
@Composable
fun DialogScreen(
onDialogSelected: (String) -> Unit,
dialogViewModel: DialogViewModel = viewModel()
) {
val dialogUiState by dialogViewModel.uiState.collectAsState()
DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected)
}
@Preview
@Composable
fun DialogPreview() {
val viewModel = DialogViewModel(MockDialogSource())
TalariaTheme {
DialogScreen(onDialogSelected = { }, dialogViewModel = viewModel)
}
}

View File

@ -1,5 +0,0 @@
package dev.lonami.talaria.ui
import dev.lonami.talaria.model.Dialog
data class DialogUiState(val dialogs: List<Dialog> = listOf())

View File

@ -1,4 +1,4 @@
package dev.lonami.talaria.ui
package dev.lonami.talaria.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -19,7 +19,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.lonami.talaria.R
import dev.lonami.talaria.model.Message
import dev.lonami.talaria.models.Message
import dev.lonami.talaria.ui.state.ChatViewModel
import dev.lonami.talaria.ui.theme.TalariaTheme
import kotlinx.coroutines.launch

View File

@ -0,0 +1,131 @@
package dev.lonami.talaria.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.lonami.talaria.R
import dev.lonami.talaria.data.MockDialogRepository
import dev.lonami.talaria.models.Dialog
import dev.lonami.talaria.models.MessageAck
import dev.lonami.talaria.ui.state.DialogViewModel
import dev.lonami.talaria.ui.theme.TalariaTheme
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun DialogCard(dialog: Dialog, onDialogSelected: () -> Unit) {
Card(
shape = MaterialTheme.shapes.large,
modifier = Modifier
.fillMaxWidth()
.padding(0.dp, 2.dp)
.clickable(onClick = onDialogSelected)
) {
Row(modifier = Modifier.padding(4.dp)) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.profile_photo),
contentScale = ContentScale.Fit,
modifier = Modifier.height(48.dp),
)
Column(modifier = Modifier.weight(1.0f)) {
Text(
dialog.title,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (dialog.lastMessage != null) {
Text(
stringResource(
R.string.message_preview,
dialog.lastMessage.sender,
dialog.lastMessage.text
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Column {
if (dialog.lastMessage != null) {
Row {
when (dialog.lastMessage.ack) {
MessageAck.RECEIVED -> {}
MessageAck.SENT -> Icon(
painterResource(R.drawable.sent),
stringResource(R.string.sent)
)
MessageAck.SEEN -> Icon(
painterResource(R.drawable.seen),
stringResource(R.string.seen)
)
}
Spacer(Modifier.width(8.dp))
Text(
dialog.lastMessage.date.format(
DateTimeFormatter.ofLocalizedTime(
FormatStyle.SHORT
)
),
)
}
}
if (dialog.pinned) {
Icon(
painterResource(R.drawable.tack),
stringResource(R.string.pinned),
modifier = Modifier.align(Alignment.End)
)
}
}
}
}
}
@Composable
fun DialogList(dialogs: List<Dialog>, onDialogSelected: (String) -> Unit) {
LazyColumn {
items(dialogs.size) {
DialogCard(dialogs[it], onDialogSelected = {
onDialogSelected(dialogs[it].id)
})
}
}
}
@Composable
fun DialogScreen(
onDialogSelected: (String) -> Unit,
dialogViewModel: DialogViewModel = viewModel()
) {
val dialogUiState by dialogViewModel.uiState.collectAsState()
DialogList(dialogUiState.dialogs, onDialogSelected = onDialogSelected)
}
@Preview
@Composable
fun DialogPreview() {
val viewModel = DialogViewModel(MockDialogRepository())
TalariaTheme {
DialogScreen(onDialogSelected = { }, dialogViewModel = viewModel)
}
}

View File

@ -1,4 +1,4 @@
package dev.lonami.talaria.ui
package dev.lonami.talaria.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions

View File

@ -1,5 +1,5 @@
package dev.lonami.talaria.ui
package dev.lonami.talaria.ui.state
import dev.lonami.talaria.model.Message
import dev.lonami.talaria.models.Message
data class ChatUiState(val messages: MutableList<Message> = mutableListOf())

View File

@ -1,9 +1,9 @@
package dev.lonami.talaria.ui
package dev.lonami.talaria.ui.state
import androidx.lifecycle.ViewModel
import dev.lonami.talaria.bindings.Native
import dev.lonami.talaria.data.MessageSource
import dev.lonami.talaria.model.Message
import dev.lonami.talaria.data.MessageRepository
import dev.lonami.talaria.models.Message
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -14,7 +14,7 @@ class ChatViewModel : ViewModel() {
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private fun loadMessages() {
_uiState.value = ChatUiState(MessageSource.loadMessages().toMutableList())
_uiState.value = ChatUiState(MessageRepository.loadMessages().toMutableList())
}
fun sendMessage(dialog: String, message: String) {

View File

@ -0,0 +1,5 @@
package dev.lonami.talaria.ui.state
import dev.lonami.talaria.models.Dialog
data class DialogUiState(val dialogs: List<Dialog> = listOf())

View File

@ -1,13 +1,14 @@
package dev.lonami.talaria.ui
package dev.lonami.talaria.ui.state
import androidx.lifecycle.ViewModel
import dev.lonami.talaria.data.DialogSource
import dev.lonami.talaria.data.NativeDialogSource
import dev.lonami.talaria.data.DialogRepository
import dev.lonami.talaria.data.NativeDialogRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DialogViewModel(private val repository: DialogSource = NativeDialogSource()) : ViewModel() {
class DialogViewModel(private val repository: DialogRepository = NativeDialogRepository()) :
ViewModel() {
private val _uiState = MutableStateFlow(DialogUiState())
val uiState: StateFlow<DialogUiState> = _uiState.asStateFlow()

View File

@ -41,4 +41,4 @@ fun TalariaTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl
shapes = Shapes,
content = content
)
}
}

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="m11,16 l2,2 9,-9C23,8 22,7 21,8l-8,8 -1,-1z"
android:fillColor="@color/black" />
<path
android:pathData="m3,14 l4,4 9,-9C17,8 16,7 15,8L7,16 4,13C3,12 2,13 3,14Z"
android:fillColor="@color/black" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="m6,14 l4,4 9,-9C20,8 19,7 18,8L10,16 7,13C6,12 5,13 6,14Z"
android:fillColor="@color/black" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="m14,4 l-5,5 -6,1 5,5 -5,7 7,-5 5,5 1,-6 5,-5c1,-1 1,-1 0,-2L16,4C15,3 15,3 14,4Z"
android:fillColor="@color/black" />
</vector>

View File

@ -15,4 +15,8 @@
<string name="back_button">Back</string>
<string name="dialog">All chats</string>
<string name="chat">Messages</string>
<string name="pinned">Pinned</string>
<string name="sent">Sent</string>
<string name="seen">Seen</string>
<string name="message_preview"><em>%s</em>: %s</string>
</resources>

View File

@ -6,6 +6,7 @@ mod db;
use grammers_client::types::{Dialog, LoginToken};
use grammers_client::{Client, Config, InitParams};
use grammers_session::{PackedChat, Session, UpdateState};
use grammers_tl_types as tl;
use jni::objects::{JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
@ -335,6 +336,111 @@ pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogTitle(
output.into_inner()
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogSender(
env: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jstring {
let dialogs = &mut *(dialogs_ptr as *mut Vec<Dialog>);
let sender = if let Some(msg) = dialogs[index as usize].last_message.as_ref() {
if let Some(sender) = msg.sender() {
sender.name().to_string()
} else {
"unknown".to_string()
}
} else {
String::new()
};
let output = env.new_string(sender).unwrap();
output.into_inner()
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogText(
env: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jstring {
let dialogs = &mut *(dialogs_ptr as *mut Vec<Dialog>);
let text = if let Some(msg) = dialogs[index as usize].last_message.as_ref() {
msg.text()
} else {
""
};
let output = env.new_string(text).unwrap();
output.into_inner()
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogTime(
env: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jstring {
let dialogs = &mut *(dialogs_ptr as *mut Vec<Dialog>);
let time = if let Some(msg) = dialogs[index as usize].last_message.as_ref() {
msg.date().to_rfc3339().to_string()
} else {
String::new()
};
let output = env.new_string(time).unwrap();
output.into_inner()
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogAck(
_: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jint {
let dialogs = &mut *(dialogs_ptr as *mut Vec<Dialog>);
let dialog = &dialogs[index as usize];
let ack = if let Some(msg) = dialog.last_message.as_ref() {
if msg.outgoing() {
match &dialog.dialog {
tl::enums::Dialog::Dialog(d) => {
if msg.id() <= d.read_inbox_max_id {
2
} else {
1
}
}
tl::enums::Dialog::Folder(_) => 0,
}
} else {
0
}
} else {
0
};
ack
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_dialogPin(
_: JNIEnv,
_: JObject,
dialogs_ptr: jlong,
index: jint,
) -> jint {
let dialogs = &mut *(dialogs_ptr as *mut Vec<Dialog>);
let pinned = match &dialogs[index as usize].dialog {
tl::enums::Dialog::Dialog(d) => d.pinned,
tl::enums::Dialog::Folder(f) => f.pinned,
};
pinned as jint
}
#[no_mangle]
pub unsafe extern "C" fn Java_dev_lonami_talaria_bindings_Native_freeDialogs(
_: JNIEnv,