diff --git a/README.md b/README.md index 6a9e493..db60573 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# IJH Android +# IJH Android [![Android CI](https://github.com/I-Info/IJH-Android/actions/workflows/ci.yml/badge.svg)](https://github.com/I-Info/IJH-Android/actions/workflows/ci.yml) IJH app for Android, a **work in progress** currently. -# Features +## Features - [ ] All features supported by **WeJH**. - [ ] Notifications. - [ ] More... -# Architecture +## Architecture The app follows the [official architecture guidance](https://developer.android.com/topic/architecture). @@ -18,18 +18,19 @@ the [official architecture guidance](https://developer.android.com/topic/archite ![module.png](https://s2.loli.net/2023/10/29/EUNtaGgBVqdfvJz.png) - data (repository -> data source) - - network (Retrofit/OkHttp) - - datastore (Protobuf) - - database (Room) + - network (Retrofit/OkHttp) + - datastore (Protobuf) + - database (Room) ## UI -UI is built with [Jetpack Compose](https://developer.android.com/jetpack/compose) and +UI is built with [Jetpack Compose](https://developer.android.com/jetpack/compose) and follows [Material Design 3](https://m3.material.io). -- Theme: IJH app uses the Dynamic color theme as possible, and provides a default theme for +- Theme: IJH app uses the Dynamic color theme (Material You), and provides a default theme for fallbacks. -## Dependency injection -IJH app uses DI(Dependency injection) between layers and uses [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) -to implement automatic DI. +## Dependency injection (DI) + +IJH app uses [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) +to implement automatic DI in modules and layers. diff --git a/app/src/main/kotlin/com/zjutjh/ijh/ui/component/CampusCardInfoCard.kt b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/CampusCardInfoCard.kt new file mode 100644 index 0000000..c29dc91 --- /dev/null +++ b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/CampusCardInfoCard.kt @@ -0,0 +1,105 @@ +package com.zjutjh.ijh.ui.component + +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.zjutjh.ijh.R +import com.zjutjh.ijh.ui.theme.IJhTheme +import com.zjutjh.ijh.util.toLocalizedString +import java.time.Duration + +@Composable +fun CampusCardInfoCard( + modifier: Modifier = Modifier, + balance: String?, + lastSyncDuration: Duration? +) { + val context = LocalContext.current + val subtitle = remember(lastSyncDuration) { + prompt(context, lastSyncDuration) + } + + GlanceCard( + modifier = modifier, + title = stringResource(id = R.string.campus_card), + subtitle = subtitle, + icon = Icons.Default.CreditCard + ) { + AnimatedContent( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + targetState = balance, + contentAlignment = Alignment.Center, + label = "Loading", + ) { + when (it) { + null -> Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + Text( + text = stringResource(id = R.string.loading), + textAlign = TextAlign.Center + ) + } + + else -> { + Text( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.primary, + text = "¥$it", + style = MaterialTheme.typography.displaySmall, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } + } + } +} + +private fun prompt(context: Context, lastSyncDuration: Duration?) = + buildString { + val separator = " • " + append(context.getString(R.string.balance)) + append(separator) + if (lastSyncDuration != null) { + append(lastSyncDuration.toLocalizedString(context)) + } else { + append(context.getString(R.string.never)) + } + } + + +@Preview +@Composable +private fun CampusCardInfoCardPreview() { + IJhTheme { + CampusCardInfoCard(balance = "123", lastSyncDuration = Duration.ofDays(1)) + } +} + +@Preview +@Composable +private fun CampusCardInfoCardPreviewEmpty() { + IJhTheme { + CampusCardInfoCard(balance = null, lastSyncDuration = null) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/zjutjh/ijh/ui/component/GlanceCard.kt b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/GlanceCard.kt new file mode 100644 index 0000000..ca3c42b --- /dev/null +++ b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/GlanceCard.kt @@ -0,0 +1,101 @@ +package com.zjutjh.ijh.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Card +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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 com.zjutjh.ijh.R +import com.zjutjh.ijh.ui.theme.IJhTheme +import com.zjutjh.ijh.util.emptyFun + +@Composable +internal fun GlanceCard( + modifier: Modifier, + title: String, + subtitle: String, + icon: ImageVector? = null, + onButtonClick: (() -> Unit)? = null, + content: @Composable (ColumnScope.() -> Unit), +) { + Card( + modifier = modifier, + ) { + Row( + Modifier + .padding(top = 12.dp, start = 16.dp, end = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + // Title + if (icon != null) + IconText( + icon = icon, + text = " | $title", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + else Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + + // Subtitle + Text( + text = subtitle, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.outline, + maxLines = 1, + ) + } + if (onButtonClick != null) + FilledTonalIconButton( + onClick = onButtonClick, + ) { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = stringResource(id = R.string.more) + ) + } + } + + content() + } +} + +@Preview +@Composable +private fun GlanceCardPreview() { + IJhTheme { + GlanceCard( + modifier = Modifier, + title = "Title", + subtitle = "Subtitle", + icon = Icons.Default.Image, + onButtonClick = ::emptyFun, + ) { + Text(modifier = Modifier.padding(24.dp), text = "Content") + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/zjutjh/ijh/ui/component/IconText.kt b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/IconText.kt new file mode 100644 index 0000000..e146d57 --- /dev/null +++ b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/IconText.kt @@ -0,0 +1,74 @@ +package com.zjutjh.ijh.ui.component + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +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.sp + +@Composable +fun IconText( + icon: ImageVector, + text: String, + contentDescription: String? = null, + fontWeight: FontWeight? = null, + style: TextStyle = MaterialTheme.typography.bodyMedium, +) { + val id = "icon" + val annotatedString = buildAnnotatedString { + appendInlineContent(id, "[icon]") + append(text) + } + val inlineContent = mapOf( + id to InlineTextContent( + Placeholder( + width = style.fontSize, + height = style.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ) + ) { + Icon( + modifier = Modifier.fillMaxSize(), + imageVector = icon, + contentDescription = contentDescription, + ) + } + ) + + Text( + text = annotatedString, + inlineContent = inlineContent, + style = style, + fontWeight = fontWeight, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) +} + +@Preview +@Composable +fun IconTextPreview() { + Surface { + IconText( + icon = Icons.Default.Image, + text = "Hello World", + fontWeight = FontWeight.Bold, + style = TextStyle(fontSize = 30.sp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/zjutjh/ijh/ui/component/ScheduleCard.kt b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/ScheduleCard.kt index 4895735..979b55b 100644 --- a/app/src/main/kotlin/com/zjutjh/ijh/ui/component/ScheduleCard.kt +++ b/app/src/main/kotlin/com/zjutjh/ijh/ui/component/ScheduleCard.kt @@ -4,25 +4,18 @@ import android.content.Context import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement 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.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Book -import androidx.compose.material.icons.filled.CalendarViewWeek +import androidx.compose.material.icons.filled.CalendarViewDay import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Divider import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -34,20 +27,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import com.zjutjh.ijh.R import com.zjutjh.ijh.data.mock.CourseRepositoryMock import com.zjutjh.ijh.model.Course @@ -62,11 +47,11 @@ import java.util.Locale @Composable fun ScheduleCard( + modifier: Modifier = Modifier, courses: List?, termDay: TermDayState?, lastSyncDuration: Duration?, - onCalendarClick: () -> Unit, - modifier: Modifier = Modifier, + onButtonClick: () -> Unit, ) { val context = LocalContext.current @@ -74,56 +59,24 @@ fun ScheduleCard( prompt(context, termDay, lastSyncDuration) } - Card( + GlanceCard( modifier = modifier, + title = stringResource(id = R.string.schedule), + subtitle = subtitle, + icon = Icons.Default.CalendarViewDay, + onButtonClick = onButtonClick, ) { - Row( - Modifier - .padding(top = 12.dp, start = 24.dp, end = 24.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - // Title - Text( - text = stringResource(id = R.string.schedule), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - // Subtitle - Text( - text = subtitle, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.outline, - maxLines = 1, - ) - } - - FilledIconButton( - onClick = onCalendarClick, - ) { - Icon( - imageVector = Icons.Default.CalendarViewWeek, - contentDescription = stringResource( - id = R.string.calendar - ) - ) - } - } - AnimatedContent( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), targetState = courses, contentAlignment = Alignment.Center, label = "Loading", ) { if (it == null) { Column( - modifier = Modifier - .padding(vertical = 16.dp) - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { CircularProgressIndicator() @@ -134,13 +87,12 @@ fun ScheduleCard( } } else if (it.isEmpty()) { Text( - modifier = Modifier.padding(vertical = 24.dp), + modifier = Modifier.padding(vertical = 8.dp), textAlign = TextAlign.Center, text = stringResource(id = R.string.nothing_to_do) ) } else { CoursesCard( - modifier = Modifier.padding(16.dp), courses = it, ) } @@ -150,7 +102,7 @@ fun ScheduleCard( private fun prompt(context: Context, termDay: TermDayState?, lastSyncDuration: Duration?) = buildString { - val separator = " | " + val separator = " • " if (termDay != null) { if (termDay.isInTerm) { append( @@ -249,44 +201,6 @@ private fun CourseListItem(course: Course, onClick: () -> Unit, modifier: Modifi } -@Composable -fun IconText( - icon: ImageVector, - text: String, - contentDescription: String? = null, - fontWeight: FontWeight? = null, - style: TextStyle = TextStyle.Default -) { - val id = "icon" - val annotatedString = buildAnnotatedString { - appendInlineContent(id, "[icon]") - append(text) - } - val inlineContent = mapOf( - id to InlineTextContent( - Placeholder( - width = 18.sp, - height = 1.em, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, - ) - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - ) - } - ) - - Text( - text = annotatedString, - inlineContent = inlineContent, - style = style, - fontWeight = fontWeight, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) -} - @Preview @Composable private fun CourseCardPreview() { @@ -310,7 +224,7 @@ private fun ScheduleSurfacePreview() { ScheduleCard( modifier = Modifier.padding(10.dp), courses = CourseRepositoryMock.getCourses(), - onCalendarClick = {}, + onButtonClick = {}, termDay = termDay, lastSyncDuration = Duration.ofDays(2), ) @@ -326,7 +240,7 @@ private fun ScheduleSurfaceEmptyPreview() { ScheduleCard( modifier = Modifier.padding(10.dp), courses = emptyList(), - onCalendarClick = {}, + onButtonClick = {}, termDay = null, lastSyncDuration = Duration.ofDays(1), ) @@ -342,7 +256,7 @@ private fun ScheduleSurfaceLoadingPreview() { ScheduleCard( modifier = Modifier.padding(10.dp), courses = null, - onCalendarClick = {}, + onButtonClick = {}, termDay = null, lastSyncDuration = Duration.ofDays(1), ) diff --git a/app/src/main/kotlin/com/zjutjh/ijh/ui/screen/HomeScreen.kt b/app/src/main/kotlin/com/zjutjh/ijh/ui/screen/HomeScreen.kt index 96f58e0..af99ac9 100644 --- a/app/src/main/kotlin/com/zjutjh/ijh/ui/screen/HomeScreen.kt +++ b/app/src/main/kotlin/com/zjutjh/ijh/ui/screen/HomeScreen.kt @@ -52,6 +52,7 @@ import com.zjutjh.ijh.R import com.zjutjh.ijh.data.mock.CourseRepositoryMock import com.zjutjh.ijh.model.Course import com.zjutjh.ijh.model.Term +import com.zjutjh.ijh.ui.component.CampusCardInfoCard import com.zjutjh.ijh.ui.component.IJhScaffold import com.zjutjh.ijh.ui.component.ScheduleCard import com.zjutjh.ijh.ui.model.TermDayState @@ -76,6 +77,7 @@ fun HomeRoute( val coursesState by viewModel.coursesState.collectAsStateWithLifecycle() val termDayState by viewModel.termDayState.collectAsStateWithLifecycle() val coursesLastSyncState by viewModel.courseLastSyncState.collectAsStateWithLifecycle() + val cardBalanceState by viewModel.cardBalanceState.collectAsStateWithLifecycle() val isLoggedIn = when (loginState) { null -> false @@ -98,6 +100,7 @@ fun HomeRoute( courses = courses, termDay = termDay, coursesLastSyncDuration = coursesLastSyncState, + cardBalance = cardBalanceState, onRefresh = viewModel::refreshAll, onNavigateToLogin = onNavigateToLogin, onNavigateToProfile = onNavigateToProfile, @@ -113,6 +116,7 @@ private fun HomeScreen( courses: List?, termDay: TermDayState?, coursesLastSyncDuration: Duration?, + cardBalance: Pair?, onRefresh: () -> Unit, onNavigateToLogin: () -> Unit, onNavigateToProfile: () -> Unit, @@ -139,16 +143,21 @@ private fun HomeScreen( Column( modifier = Modifier.padding(paddingValues) ) { - + val modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth() ScheduleCard( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), + modifier = modifier, courses = courses, termDay = termDay, - onCalendarClick = onNavigateToCalendar, + onButtonClick = onNavigateToCalendar, lastSyncDuration = coursesLastSyncDuration, ) + CampusCardInfoCard( + modifier = modifier, + balance = cardBalance?.first, + lastSyncDuration = cardBalance?.second + ) } PullRefreshIndicator( @@ -339,31 +348,15 @@ private fun HomeScreenPreview() { HomeScreen( refreshing = false, isLoggedIn = true, - courses, - termDay, - Duration.ofDays(1), - {}, - {}, - {}, - {}) {} - } -} - -@Preview(heightDp = 400) -@Composable -private fun HomeScrollPreview() { - val courses = CourseRepositoryMock.getCourses() - val termDay = TermDayState(2023, Term.FIRST, 1, true, DayOfWeek.MONDAY) - IJhTheme { - HomeScreen( - refreshing = false, - isLoggedIn = true, - courses, - termDay, - Duration.ofDays(1), - {}, - {}, - {}, - {}) {} + courses = courses, + termDay = termDay, + coursesLastSyncDuration = Duration.ofDays(1), + cardBalance = "123" to Duration.ofDays(2), + onRefresh = ::emptyFun, + onNavigateToAbout = ::emptyFun, + onNavigateToCalendar = ::emptyFun, + onNavigateToLogin = ::emptyFun, + onNavigateToProfile = ::emptyFun, + ) } } diff --git a/app/src/main/kotlin/com/zjutjh/ijh/ui/viewmodel/HomeViewModel.kt b/app/src/main/kotlin/com/zjutjh/ijh/ui/viewmodel/HomeViewModel.kt index 3ee6852..bf4f753 100644 --- a/app/src/main/kotlin/com/zjutjh/ijh/ui/viewmodel/HomeViewModel.kt +++ b/app/src/main/kotlin/com/zjutjh/ijh/ui/viewmodel/HomeViewModel.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zjutjh.ijh.data.CampusInfoRepository +import com.zjutjh.ijh.data.CardInfoRepository import com.zjutjh.ijh.data.CourseRepository import com.zjutjh.ijh.data.WeJhUserRepository import com.zjutjh.ijh.model.Course @@ -27,6 +28,7 @@ class HomeViewModel @Inject constructor( weJhUserRepository: WeJhUserRepository, private val courseRepository: CourseRepository, private val campusInfoRepository: CampusInfoRepository, + private val cardInfoRepository: CardInfoRepository, ) : ViewModel() { private val timerFlow: Flow = flow { @@ -95,9 +97,24 @@ class HomeViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000) ) + val cardBalanceState: StateFlow?> = cardInfoRepository.balanceStream + .distinctUntilChanged() + .combine(timerFlow) { t, _ -> t } + .map { + if (it == null) null else { + Pair(it.first, Duration.between(it.second, ZonedDateTime.now())) + } + } + .flowOn(Dispatchers.Default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null + ) + init { viewModelScope.launch(Dispatchers.Default) { - // Check session state + // Check session state, renew if needed. TODO: move to application scope val session = loginState.first() if (session != null) { val duration = Duration.between(ZonedDateTime.now(), session.expiresAt) @@ -110,15 +127,14 @@ class HomeViewModel @Inject constructor( weJhUserRepository.renewSession() Log.i("Home", "WeJH Session renewed.") } catch (e: Exception) { - Log.e("Home", "Failed to renew WeJH Session: $e") + Log.e("Home", "Failed to renew WeJH Session.", e) } } } // Subscribe latest login state, and trigger refresh. - loginState - .collectLatest { - refreshAll(this) - } + loginState.collectLatest { + refreshAll(this) + } } } @@ -146,6 +162,7 @@ class HomeViewModel @Inject constructor( if (term != null) { refreshCourse(term.first, term.second) } + refreshCard() } Log.i("Home", "Synchronization complete.") @@ -160,7 +177,7 @@ class HomeViewModel @Inject constructor( Log.i("Home", "Sync WeJhInfo succeed.") return it }) { - Log.e("Home", "Sync WeJhInfo failed: $it") + Log.e("Home", "Sync WeJhInfo failed.", it) // Run local refresh when failed termLocalRefreshChannel.emit(Unit) if (termDayState.value is LoadResult.Ready) { @@ -179,7 +196,17 @@ class HomeViewModel @Inject constructor( }.fold({ Log.i("Home", "Sync Courses succeed.") }) { - Log.e("Home", "Sync Courses failed: $it") + Log.e("Home", "Sync Courses failed.", it) + } + } + + private suspend fun refreshCard() { + runCatching { + cardInfoRepository.sync() + }.fold({ + Log.i("Home", "Sync Card succeed.") + }) { + Log.e("Home", "Sync Card failed.", it) } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2e5c361..b80df50 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -67,4 +67,7 @@ 关于 客户端由 [dev] 开发,服务由 [serv] 提供。 今日课程 + 更多 + 余额 + 校园卡 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3b7d7c..85094d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,8 +37,8 @@ Collapse " day(s) ago" " hour(s) ago" - " minute(s) ago" - " second(s) ago" + " min ago" + " sec ago" Just Never Loading… @@ -71,4 +71,7 @@ https://github.com/zjutjh/WeJH-Go https://github.com/I-Info/IJH-Android Schedule + More + Balance + Campus Card \ No newline at end of file diff --git a/core/common/src/main/kotlin/com/zjutjh/ijh/model/CardRecord.kt b/core/common/src/main/kotlin/com/zjutjh/ijh/model/CardRecord.kt new file mode 100644 index 0000000..091f04f --- /dev/null +++ b/core/common/src/main/kotlin/com/zjutjh/ijh/model/CardRecord.kt @@ -0,0 +1,14 @@ +package com.zjutjh.ijh.model + +import androidx.compose.runtime.Stable + +@Stable +data class CardRecord( + val address: String, + val dealTime: String, + val feeName: String, + val money: String, + val serialNo: String, + val time: String, + val type: String, +) \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/zjutjh/ijh/data/BalanceRepository.kt b/core/data/src/main/kotlin/com/zjutjh/ijh/data/BalanceRepository.kt deleted file mode 100644 index 9c70487..0000000 --- a/core/data/src/main/kotlin/com/zjutjh/ijh/data/BalanceRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zjutjh.ijh.data - -interface BalanceRepository { - - /** - * Get balance directly from network, because the non-realtime data is useless - * - * @return balance (Unit: CNY) in string - */ - suspend fun getBalance(): String -} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/zjutjh/ijh/data/CardInfoRepository.kt b/core/data/src/main/kotlin/com/zjutjh/ijh/data/CardInfoRepository.kt new file mode 100644 index 0000000..8aaf26d --- /dev/null +++ b/core/data/src/main/kotlin/com/zjutjh/ijh/data/CardInfoRepository.kt @@ -0,0 +1,18 @@ +package com.zjutjh.ijh.data + +import com.zjutjh.ijh.model.CardRecord +import kotlinx.coroutines.flow.Flow +import java.time.ZonedDateTime +import java.util.Date + +interface CardInfoRepository { + + /** + * [Pair]: balance (Unit: CNY) in string, last sync time + */ + val balanceStream: Flow?> + + suspend fun sync() + + suspend fun getRecord(date: Date): List +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/zjutjh/ijh/data/di/DataModule.kt b/core/data/src/main/kotlin/com/zjutjh/ijh/data/di/DataModule.kt index b450a41..46b9ec4 100644 --- a/core/data/src/main/kotlin/com/zjutjh/ijh/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/zjutjh/ijh/data/di/DataModule.kt @@ -1,9 +1,11 @@ package com.zjutjh.ijh.data.di import com.zjutjh.ijh.data.CampusInfoRepository +import com.zjutjh.ijh.data.CardInfoRepository import com.zjutjh.ijh.data.CourseRepository import com.zjutjh.ijh.data.WeJhUserRepository import com.zjutjh.ijh.data.impl.CampusInfoRepositoryImpl +import com.zjutjh.ijh.data.impl.CardInfoRepositoryImpl import com.zjutjh.ijh.data.impl.CourseRepositoryImpl import com.zjutjh.ijh.data.impl.WeJhUserRepositoryImpl import dagger.Binds @@ -23,4 +25,7 @@ interface DataModule { @Binds fun bindWeJhInfoRepository(impl: CampusInfoRepositoryImpl): CampusInfoRepository + + @Binds + fun bindCardInfoRepository(impl: CardInfoRepositoryImpl): CardInfoRepository } \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/zjutjh/ijh/data/impl/CardInfoRepositoryImpl.kt b/core/data/src/main/kotlin/com/zjutjh/ijh/data/impl/CardInfoRepositoryImpl.kt new file mode 100644 index 0000000..8d599b7 --- /dev/null +++ b/core/data/src/main/kotlin/com/zjutjh/ijh/data/impl/CardInfoRepositoryImpl.kt @@ -0,0 +1,35 @@ +package com.zjutjh.ijh.data.impl + +import com.zjutjh.ijh.data.CardInfoRepository +import com.zjutjh.ijh.datastore.WeJhPreferenceDataSource +import com.zjutjh.ijh.datastore.converter.toZonedDateTime +import com.zjutjh.ijh.datastore.model.cardOrNull +import com.zjutjh.ijh.model.CardRecord +import com.zjutjh.ijh.network.CardInfoDataSource +import com.zjutjh.ijh.network.model.NetworkCardRecord +import com.zjutjh.ijh.network.model.asExternalModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.ZonedDateTime +import java.util.Date +import javax.inject.Inject + +class CardInfoRepositoryImpl @Inject constructor( + private val network: CardInfoDataSource, + private val local: WeJhPreferenceDataSource +) : CardInfoRepository { + + override val balanceStream: Flow?> = local.data.map { + it.cardOrNull?.let { card -> + Pair(card.balance, card.lastSyncTime.toZonedDateTime()) + } + } + + override suspend fun sync() { + val balance = network.getBalance() + local.setCard(balance, ZonedDateTime.now()) + } + + override suspend fun getRecord(date: Date): List = + network.getRecord(date).map(NetworkCardRecord::asExternalModel) +} \ No newline at end of file diff --git a/core/datastore/src/main/kotlin/com/zjutjh/ijh/datastore/WeJhPreferenceDataSource.kt b/core/datastore/src/main/kotlin/com/zjutjh/ijh/datastore/WeJhPreferenceDataSource.kt index eb1726a..9db4051 100644 --- a/core/datastore/src/main/kotlin/com/zjutjh/ijh/datastore/WeJhPreferenceDataSource.kt +++ b/core/datastore/src/main/kotlin/com/zjutjh/ijh/datastore/WeJhPreferenceDataSource.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import com.zjutjh.ijh.datastore.converter.asLocalModel import com.zjutjh.ijh.datastore.model.LocalSession import com.zjutjh.ijh.datastore.model.WeJhPreference +import com.zjutjh.ijh.datastore.model.WeJhPreferenceKt import com.zjutjh.ijh.datastore.model.copy import com.zjutjh.ijh.model.CampusInfo import com.zjutjh.ijh.model.WeJhUser @@ -58,7 +59,7 @@ class WeJhPreferenceDataSource @Inject constructor(private val dataStore: DataSt } } - suspend fun setInfo(info: WeJhPreference.Info) = + private suspend fun setInfo(info: WeJhPreference.Info) = dataStore.updateData { it.copy { this.info = info @@ -88,4 +89,25 @@ class WeJhPreferenceDataSource @Inject constructor(private val dataStore: DataSt clearSession() } } + + private suspend fun setCard(card: WeJhPreference.Card) = + dataStore.updateData { + it.copy { + this.card = card + } + } + + suspend fun setCard(balance: String, syncTime: ZonedDateTime) = + setCard(WeJhPreferenceKt.card { + this.balance = balance + lastSyncTime = syncTime.toEpochSecond() + }) + + suspend fun deleteCard() = + dataStore.updateData { + it.copy { + clearCard() + } + } + } \ No newline at end of file diff --git a/core/datastore/src/main/proto/com/zjutjh/ijh/datastore/we_jh_preference.proto b/core/datastore/src/main/proto/com/zjutjh/ijh/datastore/we_jh_preference.proto index c795147..cbead93 100644 --- a/core/datastore/src/main/proto/com/zjutjh/ijh/datastore/we_jh_preference.proto +++ b/core/datastore/src/main/proto/com/zjutjh/ijh/datastore/we_jh_preference.proto @@ -12,6 +12,8 @@ message WeJhPreference { optional uint64 courses_last_sync_time = 4; + optional Card card = 5; + // Campus info message Info { uint32 year = 1; @@ -21,6 +23,11 @@ message WeJhPreference { string school_bus_url = 5; } + message Card { + string balance = 1; + uint64 last_sync_time = 2; + } + message User { string username = 1; int64 uid = 2; diff --git a/core/network/src/main/kotlin/com/zjutjh/ijh/network/CardInfoDataSource.kt b/core/network/src/main/kotlin/com/zjutjh/ijh/network/CardInfoDataSource.kt new file mode 100644 index 0000000..50b6958 --- /dev/null +++ b/core/network/src/main/kotlin/com/zjutjh/ijh/network/CardInfoDataSource.kt @@ -0,0 +1,20 @@ +package com.zjutjh.ijh.network + +import com.zjutjh.ijh.network.model.NetworkCardRecord +import com.zjutjh.ijh.network.service.WeJhCardService +import java.time.format.DateTimeFormatter +import java.util.Date +import javax.inject.Inject + +class CardInfoDataSource @Inject constructor(private val cardService: WeJhCardService) { + + suspend fun getBalance(): String = cardService.getBalance() + + suspend fun getRecord(date: Date): List { + val formatter = DateTimeFormatter.BASIC_ISO_DATE + val queryTime = formatter.format(date.toInstant()) + return cardService.getRecord( + WeJhCardService.QueryTime(queryTime) + ) + } +} \ No newline at end of file diff --git a/core/network/src/main/kotlin/com/zjutjh/ijh/network/model/NetworkCardRecord.kt b/core/network/src/main/kotlin/com/zjutjh/ijh/network/model/NetworkCardRecord.kt new file mode 100644 index 0000000..67234e8 --- /dev/null +++ b/core/network/src/main/kotlin/com/zjutjh/ijh/network/model/NetworkCardRecord.kt @@ -0,0 +1,29 @@ +package com.zjutjh.ijh.network.model + +import com.squareup.moshi.JsonClass +import com.zjutjh.ijh.model.CardRecord + +/** + * Card consumption record model + */ +@JsonClass(generateAdapter = true) +data class NetworkCardRecord( + val address: String, + val dealTime: String, + val feeName: String, + val money: String, + val serialNo: String, + val time: String, + val type: String, +) + +fun NetworkCardRecord.asExternalModel() = + CardRecord( + address = address, + dealTime = dealTime, + feeName = feeName, + money = money, + serialNo = serialNo, + time = time, + type = type, + ) \ No newline at end of file diff --git a/core/network/src/main/kotlin/com/zjutjh/ijh/network/service/WeJhCardService.kt b/core/network/src/main/kotlin/com/zjutjh/ijh/network/service/WeJhCardService.kt new file mode 100644 index 0000000..74c34bd --- /dev/null +++ b/core/network/src/main/kotlin/com/zjutjh/ijh/network/service/WeJhCardService.kt @@ -0,0 +1,25 @@ +package com.zjutjh.ijh.network.service + +import com.squareup.moshi.JsonClass +import com.zjutjh.ijh.network.model.NetworkCardRecord +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import java.time.format.DateTimeFormatter + +interface WeJhCardService { + + @GET("balance") + suspend fun getBalance(): String + + @POST("record") + suspend fun getRecord(@Body queryTime: QueryTime): List + + @JsonClass(generateAdapter = true) + data class QueryTime( + /** + * format: yyyyMMdd [DateTimeFormatter.BASIC_ISO_DATE] + */ + val queryTime: String, + ) +} \ No newline at end of file diff --git a/core/network/src/main/kotlin/com/zjutjh/ijh/network/service/di/ServiceModule.kt b/core/network/src/main/kotlin/com/zjutjh/ijh/network/service/di/ServiceModule.kt index bb391ed..e1169d3 100644 --- a/core/network/src/main/kotlin/com/zjutjh/ijh/network/service/di/ServiceModule.kt +++ b/core/network/src/main/kotlin/com/zjutjh/ijh/network/service/di/ServiceModule.kt @@ -4,6 +4,7 @@ import com.zjutjh.ijh.network.BuildConfig import com.zjutjh.ijh.network.di.DefaultOkHttpClient import com.zjutjh.ijh.network.di.WeJhAuthorizedOkHttpClient import com.zjutjh.ijh.network.service.WeJhBasicService +import com.zjutjh.ijh.network.service.WeJhCardService import com.zjutjh.ijh.network.service.WeJhUserService import com.zjutjh.ijh.network.service.WeJhZfService import dagger.Module @@ -24,14 +25,12 @@ object ServiceModule { Retrofit.Builder() .addConverterFactory(MoshiConverterFactory.create()) - private fun retrofitWeJhServiceBuilder(): Retrofit.Builder = - retrofitCommonBuilder() - .baseUrl(BuildConfig.WE_JH_API_BASE_URL) @Provides @Singleton - fun provideWeJhService(@DefaultOkHttpClient client: OkHttpClient): WeJhBasicService = - retrofitWeJhServiceBuilder() + fun provideWeJhBasicService(@DefaultOkHttpClient client: OkHttpClient): WeJhBasicService = + retrofitCommonBuilder() + .baseUrl(BuildConfig.WE_JH_API_BASE_URL) .client(client) .build() .create() @@ -42,7 +41,7 @@ object ServiceModule { fun provideWeJhUserService( @WeJhAuthorizedOkHttpClient client: OkHttpClient ): WeJhUserService = - retrofitWeJhServiceBuilder() + retrofitCommonBuilder() .baseUrl(BuildConfig.WE_JH_API_BASE_URL + "user/") .client(client) .build() @@ -58,4 +57,15 @@ object ServiceModule { .client(client) .build() .create() + + @Provides + @Singleton + fun provideWeJhCardService( + @WeJhAuthorizedOkHttpClient client: OkHttpClient + ): WeJhCardService = + retrofitCommonBuilder() + .baseUrl(BuildConfig.WE_JH_API_BASE_URL + "func/card/") + .client(client) + .build() + .create() } \ No newline at end of file