diff --git a/.gitignore b/.gitignore index 587723f..03c9c87 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.aar *.ap_ *.aab +output-metadata.json # Files for the ART/Dalvik VM *.dex diff --git a/README.md b/README.md index bb63985..2db776c 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ Next generation of DanXi. -# 🚧 Maintenance Notice -Because of a severe lack of human resources, we decide to pause the development on Android native version of DanXi. Please check our main repository: . - # Development Track + See issue [#5](https://github.com/DanXi-Dev/DanXi-NG/issues/5). + # Contribution Guides + Please first have a look at our [style guidelines](STYLE-PRACTICES.md). diff --git a/STYLE-PRACTICES.md b/STYLE-PRACTICES.md index 91d8191..101ec31 100644 --- a/STYLE-PRACTICES.md +++ b/STYLE-PRACTICES.md @@ -23,69 +23,37 @@ clean ## 应用规范 -### **要**使用 ViewBinding。 +### **要**使用依赖注入(Dependency Injection)。 -ViewBinding 是新版本 Android 开发支持库提供的自动生成类,其名称是布局文件名的驼峰转写。 +旦夕引入了 [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) 作为 ViewModel 的依赖管理工具, +因为 Hilt 可以把依赖作用域限制在 `ViewModel` 或者 `Application` 的级别,不同的 `ViewModel` 可以共享不同的全局变量。 +同时 Hilt 是编译时注入,不会影响运行时性能。 #### 好👍 ```kotlin -private val binding: ActivityMainBinding by lazy { ActivityMainBinding.inflate(LayoutInflater.from(this)) } -// 在稍后的地方设置布局 -setContentView(binding.root) -``` - -#### 坏👎 - -```kotlin -// 直接设置布局 ID -setContentView(R.layout.activity_main) -``` - -### **要**使用服务定位器(Service Locator)模式。 - -旦夕引入了 [Koin](https://insert-koin.io/) 作为全局变量的管理工具,而非 Android 官方提供的 Dagger 或其他依赖注入(Dependency Injection)库,这是因为后者的灵活性很差,仅允许对 Activity、ViewModel 和 Fragment 注入依赖。 - -#### 好👍 - -```kotlin -// 在 GlobalState.kt 设置全局变量的生成器 -val appModule = module { +// 使用 Annotation 设置依赖 +@Singleton +class MyGlobalClass @Inject constructor() { // ... - single { MyGlobalVariable() } -} -// 在导入 KoinComponent 其他地方使用 -private val globalValue: MyGlobalVariable by inject() -``` - -#### 不是很坏,但是不推荐😕 - -```kotlin -// 在任何地方都用 KoinComponent 导入全局变量,无论它是哪一层的控件,甚至根本不是控件。 -class MySimpleToolClass : KoinComponent { - private val globalValue: MyGlobalVariable by inject() } -// ❗在这种时候,应优先让类接受参数,而不是自己硬编码全局变量: -class MySimpleToolClass(private val globalValue: MyGlobalVariable) { -} -``` - -#### 坏👎 - -```kotlin -// 直接创建全局静态变量 -companion object { - var globalValue = MyGlobalValue() +// 在其他地方注入依赖 +@ViewModelScoped +class MyViewModelScopedClass @Inject constructor( + myGlobalClass: MyGlobalClass +) { + // ... } -``` -#### 坏👎 - -```kotlin -// 储存 Context(如 Application、Activity、Service 等都是 Context) -companion object { - var globalValue: Acitivty = getActivity() +// 在 `ViewModel` 中注入依赖 +@HiltViewModel +class MyViewModel @inject constructor( + myGlobalClass: MyGlobalClass +) : ViewModel() { + // 获取 Context + @ApplicationContext lateinit var context: Context + // ... } ``` @@ -140,13 +108,8 @@ suspend fun getDataFromNetwork(): String? { #### 好👍 ```kotlin -suspend fun getDataFromNetwork(): String { - withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - val res = innerCallThatBlocksThread() - it.resume(res) - } - } +suspend fun getDataFromNetwork() = withContext(Dispatchers.IO){ + networkRequest() } ``` @@ -154,15 +117,10 @@ suspend fun getDataFromNetwork(): String { ```kotlin fun getDataFromNetwork(): String { - val res = innerCallThatBlocksThread() - return res + return networkRequest() } ``` -> **例外** -> -> 在网络请求层内的私有方法可以任意决定。实际上,对于总是被网络请求层的其他 suspend 方法调用的方法,建议使用「坏👎」中的写法。 - ### **不要**在网络请求层过多地「消化」掉异常。 #### 好👍 @@ -215,7 +173,7 @@ suspend fun getDataFromNetwork(): String { > > 除非该异常是**可恢复的**。可恢复的定义是:即便不执行本身的主要逻辑第二次(例如:重新请求网络),也可以返回正确的结果。 -### **要**在控制层处理来自数据请求层的异常。 +### **要**在控制层( `ViewModel` 层)处理来自数据请求层的异常。 #### 好👍 @@ -249,6 +207,10 @@ fun clickRefreshData() { ```kotlin // ViewModel 中 +data class MyUiState( + val clicked: Boolean = false +) + private val _uiState = MutableStateFlow(MyUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -259,22 +221,12 @@ fun onClick() { // -------------------------------- -// Fragment/Activity 中 -data class MyUiState( - val clicked: Boolean = false -) - -// 或者对于 Activity,使用 onCreate() 回调 -override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.apply { - watch(this@repeatOnLifecycle, { it.clicked }) { - // clicked 变更的事件发生 - } - } - } +// Compose 中 +@Composable +fun MyComposable(viewModel: MyViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Button(onClick = { viewModel.onClick() }) { + Text("Click me") } } ``` @@ -285,28 +237,30 @@ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ### **不推荐**在视图层以外调用界面方法。 -视图层(如 `Activity` 和 `Fragment`)理应承担所有视图任务,如显示动画、显示对话框、显示上下文菜单、跳转到新页面等等。其他层,尤其是 `ViewModel`,不应当执行任何有关方法。 +视图层(如 `Compose`)理应承担所有视图任务,如显示动画、显示对话框、显示上下文菜单、跳转到新页面等等。其他层,尤其是 `ViewModel`,不应当执行任何有关方法。 > **例外** > -> 在自定义的视图-控制器一体类(如 `Feature`)中,`startActivity` 是可容忍的。 +> 在自定义的视图-控制器一体类(如 `Feature`)中,`navigate` 是可容忍的。 ## 应用惯例 -### **不要**过多地在通用类中使用只针对复旦 UIS 账号的实现。 +### **要**针对不同账户系统使用不同的数据类型。 -旦夕是面向多类型账户系统的,不应想当然地把 `PersonInfo` 视作只包含 UIS 账号密码的数据类型,也不要在设置中过多出现「复旦」有关的字样。 +旦夕是面向多类型账户系统的,各账户系统可以独立登录登出,因此需要用独立的数据类型存储。`UISInfo` 是只针对 UIS 账号密码的数据类型,`OTJWTToken` 是只针对 FDUHole 凭证的数据类型。 + +另外,需要区分账户「凭证」和账户「信息」,前者仅包含登录所需的信息,可以本地存储;后者包含所有信息,应该在登录后或者启动应用时从后端获取。 ``` # 好👍 +登录复旦 UIS 账户 +使用树洞账号登录旦课 +树洞登录 +无法连接至复旦 UIS 服务器 + +# 坏👎 登录账户 使用旦夕账号登录旦课 树洞登录 无法连接至服务器 - -# 坏👎 -登录 UIS 账户 -使用复旦树洞账号登录旦课 -复旦树洞登录 -无法连接至复旦服务器 ``` \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 850c755..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,90 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.10' -} - -android { - compileSdk 32 - - defaultConfig { - applicationId "com.fduhole.danxinative" - minSdk 21 - targetSdk 32 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - buildFeatures { - viewBinding true - } -} - -dependencies { - // Dependency injection - implementation 'io.insert-koin:koin-android:3.2.0' - // HTTP client - implementation 'com.squareup.okhttp3:okhttp:4.10.0' - implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.10.0' - // HTTP API client - implementation 'com.squareup.retrofit2:retrofit:2.9.0' - // JSON serialization - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0-RC" - implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0" - // Datetime support - implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0" - // HTML parser - implementation 'org.jsoup:jsoup:1.15.2' - // Paging support - implementation "androidx.paging:paging-runtime:3.1.1" - // Regex and other support - implementation "org.jetbrains.kotlin:kotlin-stdlib:1.7.10" - // Encrypted SharedPreferences - // 2022-08-27 Note (@w568w): `security-crypto-ktx:1.1.0-alpha03` seems to only support API 23+, while the library - // without `-ktx` supports 21+. - implementation "androidx.security:security-crypto:1.1.0-alpha03" - // About page - implementation 'com.drakeet.about:about:2.5.2' - implementation 'com.drakeet.multitype:multitype:4.3.0' - // Permission request - implementation 'com.guolindev.permissionx:permissionx:1.6.4' - // QRCode - implementation 'com.google.zxing:core:3.5.0' - - - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.appcompat:appcompat:1.4.2' - implementation 'com.google.android.material:material:1.6.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0' - implementation 'androidx.navigation:navigation-ui-ktx:2.5.0' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0' - implementation 'androidx.preference:preference:1.2.0' - implementation 'androidx.annotation:annotation:1.4.0' - - testImplementation 'junit:junit:4.13.2' - testImplementation "io.insert-koin:koin-test:3.2.0" - testImplementation "io.insert-koin:koin-test-junit4:3.2.0" - testImplementation 'io.github.ivanshafran:shared-preferences-mock:1.2.4' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e93297e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,128 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kapt) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.ktorfit) +} + +android { + compileSdk = 34 + namespace = "com.fduhole.danxi" + + defaultConfig { + applicationId = "com.fduhole.danxi" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables.useSupportLibrary = true + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } + kotlinOptions { + jvmTarget = "17" + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + androidResources { + generateLocaleConfig = true + } +} + +dependencies { + + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + // Kotlin + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + + // Androidx + implementation(libs.androidx.appcompat) + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.annotation) + implementation(libs.androidx.paging) + implementation(libs.androidx.security) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.hilt.navigation.compose) + + // Androidx Compose + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.ui.googlefonts) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.paging.compose) + implementation(libs.google.accompanist.permissions) + + // Security DataStore + implementation(libs.security.crypto.datastore.preferences) + + // Dependency injection + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + // HTTP client + implementation(libs.ktor.client) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktorfit.lib) + ksp(libs.ktorfit.ksp) + // HTML parser + implementation(libs.jsoup) + // QRCode + implementation(libs.google.zxing.core) + + implementation(libs.google.errorprone.annotations) + implementation(libs.slf4j.simple) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit.ext) + androidTestImplementation(libs.androidx.test.espresso) + androidTestImplementation(composeBom) + androidTestImplementation(libs.androidx.compose.test.junit4) + // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test + testImplementation(libs.kotlinx.coroutines.test) + + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.test.manifest) +} + +kapt { + correctErrorTypes = true +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/fduhole/danxinative/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/fduhole/danxi/ExampleInstrumentedTest.kt similarity index 84% rename from app/src/androidTest/java/com/fduhole/danxinative/ExampleInstrumentedTest.kt rename to app/src/androidTest/java/com/fduhole/danxi/ExampleInstrumentedTest.kt index 67ceb34..4a9f715 100644 --- a/app/src/androidTest/java/com/fduhole/danxinative/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/fduhole/danxi/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.fduhole.danxinative +package com.fduhole.danxi import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.fduhole.danxinative", appContext.packageName) + assertEquals("com.fduhole.danxi", appContext.packageName) } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 661ec05..5ebd261 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> @@ -14,32 +13,19 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@style/Theme.DanXiNative" + android:theme="@style/Theme.DanXi" android:usesCleartextTraffic="true" - tools:targetApi="s"> - - + android:enableOnBackInvokedCallback="true" + tools:targetApi="tiramisu"> + - - + android:theme="@style/Theme.DanXi"> diff --git a/app/src/main/java/com/fduhole/danxi/DanXiApplication.kt b/app/src/main/java/com/fduhole/danxi/DanXiApplication.kt new file mode 100644 index 0000000..3ff8563 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/DanXiApplication.kt @@ -0,0 +1,44 @@ +package com.fduhole.danxi + +import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.github.osipxd.security.crypto.createEncrypted +import javax.inject.Singleton + +@HiltAndroidApp +class DanXiApplication : Application() { + + companion object { + const val DAN_XI_PREFERENCE_NAME = "settings" + } +} + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + @Singleton + fun provideDataStore(@ApplicationContext context: Context): DataStore { + return PreferenceDataStoreFactory.createEncrypted { + EncryptedFile.Builder( + context, + context.dataStoreFile("${DanXiApplication.DAN_XI_PREFERENCE_NAME}.preferences_pb"), // The file should have extension .preferences_pb + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ).build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/MainActivity.kt b/app/src/main/java/com/fduhole/danxi/MainActivity.kt new file mode 100644 index 0000000..2bdb23a --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/MainActivity.kt @@ -0,0 +1,42 @@ +package com.fduhole.danxi + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.fduhole.danxi.ui.DanXiNavGraph +import com.fduhole.danxi.ui.GlobalViewModel +import com.fduhole.danxi.ui.theme.DanXiNativeTheme +import dagger.hilt.android.AndroidEntryPoint + +// @see https://github.com/android/user-interface-samples/blob/main/PerAppLanguages/compose_app/app/src/main/java/com/example/perapplanguages/MainActivity.kt +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MainApp() + } + } +} + +@Composable +fun MainApp() { + val globalViewModel: GlobalViewModel = viewModel() + val uiState by globalViewModel.uiState.collectAsStateWithLifecycle() + val isDarkTheme = uiState.isDarkTheme ?: isSystemInDarkTheme() + DanXiNativeTheme(isDarkTheme) { + Surface(color = MaterialTheme.colorScheme.background) { + DanXiNavGraph( + globalViewModel = globalViewModel, + ) + } + } +} diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/AAONotice.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/AAONotice.kt new file mode 100644 index 0000000..dcb0a83 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/model/fdu/AAONotice.kt @@ -0,0 +1,10 @@ +package com.fduhole.danxi.model.fdu + +import kotlinx.serialization.Serializable + +@Serializable +data class AAONotice( + val title: String, + val url: String, + val time: String, +) diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/CardPersonInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/CardPersonInfo.kt new file mode 100644 index 0000000..0245009 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/model/fdu/CardPersonInfo.kt @@ -0,0 +1,11 @@ +package com.fduhole.danxi.model.fdu + +import kotlinx.serialization.Serializable + +@Serializable +data class CardPersonInfo( + val balance: String, + val name: String, + val recentRecord: List +) + diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/CardRecord.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/CardRecord.kt new file mode 100644 index 0000000..0b429f2 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/model/fdu/CardRecord.kt @@ -0,0 +1,14 @@ +package com.fduhole.danxi.model.fdu + +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class CardRecord( + val time: Instant, + val type: String, + val location: String, + val amount: String, + val balance: String, +) + diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/EhallStudentInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/EhallStudentInfo.kt new file mode 100644 index 0000000..a2b984e --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/model/fdu/EhallStudentInfo.kt @@ -0,0 +1,3 @@ +package com.fduhole.danxi.model.fdu + +data class EhallStudentInfo(val name: String, val userTypeName: String, val userDepartment: String) \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/LibraryInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/LibraryInfo.kt new file mode 100644 index 0000000..678c82a --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/model/fdu/LibraryInfo.kt @@ -0,0 +1,13 @@ +package com.fduhole.danxi.model.fdu + +import kotlinx.serialization.Serializable + +@Serializable +data class LibraryInfo( + val campusId: String, + val campusName: String, + val inNum: String, + val libraryOpenTime: String, + val placeNum: String, +) + diff --git a/app/src/main/java/com/fduhole/danxi/model/fdu/UISInfo.kt b/app/src/main/java/com/fduhole/danxi/model/fdu/UISInfo.kt new file mode 100644 index 0000000..1adc423 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/model/fdu/UISInfo.kt @@ -0,0 +1,12 @@ +package com.fduhole.danxi.model.fdu + +import kotlinx.serialization.Serializable + +@Serializable +data class UISInfo( + val id: String, + val password: String, + val name: String = "", +) { + override fun toString(): String = String.format("%s (%s)", name, id) +} diff --git a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Beans.kt b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Beans.kt similarity index 89% rename from app/src/main/java/com/fduhole/danxinative/model/opentreehole/Beans.kt rename to app/src/main/java/com/fduhole/danxi/model/opentreehole/Beans.kt index b826637..ef8c9f8 100644 --- a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Beans.kt +++ b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Beans.kt @@ -1,4 +1,4 @@ -package com.fduhole.danxinative.model.opentreehole +package com.fduhole.danxi.model.opentreehole import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Models.kt b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Models.kt similarity index 98% rename from app/src/main/java/com/fduhole/danxinative/model/opentreehole/Models.kt rename to app/src/main/java/com/fduhole/danxi/model/opentreehole/Models.kt index 8d57187..0d13c61 100644 --- a/app/src/main/java/com/fduhole/danxinative/model/opentreehole/Models.kt +++ b/app/src/main/java/com/fduhole/danxi/model/opentreehole/Models.kt @@ -1,4 +1,4 @@ -package com.fduhole.danxinative.model.opentreehole +package com.fduhole.danxi.model.opentreehole import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/fduhole/danxi/repository/BaseRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/BaseRepository.kt new file mode 100644 index 0000000..393c2b7 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/BaseRepository.kt @@ -0,0 +1,65 @@ +package com.fduhole.danxi.repository + +import com.fduhole.danxi.util.net.MemoryCookiesStorage +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.cio.CIO +import io.ktor.client.engine.cio.CIOEngineConfig +import io.ktor.client.engine.cio.endpoint +import io.ktor.client.plugins.HttpRedirect +import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage +import io.ktor.client.plugins.cookies.CookiesStorage +import io.ktor.client.plugins.cookies.HttpCookies + + +abstract class BaseRepository { + companion object { + private val clients: MutableMap = mutableMapOf() + private val cookiesStorages: MutableMap = mutableMapOf() + + fun createTmpClient( + cookiesStorage: CookiesStorage = AcceptAllCookiesStorage(), + block: HttpClientConfig.() -> Unit = {}, + ) = HttpClient(CIO) { + engine { + endpoint { + connectTimeout = 50_000 + requestTimeout = 50_000 + } + } + install(HttpCookies) { + storage = cookiesStorage + } + install(HttpRedirect) { + checkHttpMethod = false // not work with CIO + } + block() + } + } + + val cookiesStorage: MemoryCookiesStorage + get() { + if (!cookiesStorages.containsKey(scopeId)) { + cookiesStorages[scopeId] = MemoryCookiesStorage(AcceptAllCookiesStorage()) + } + return cookiesStorages[scopeId]!! + } + + val client: HttpClient + get() { + if (!clients.containsKey(scopeId)) { + clients[scopeId] = createClient() + } + return clients[scopeId]!! + } + + open fun createClient(): HttpClient = createTmpClient(cookiesStorage) + + + /** + * Get the scope id of the repository. + * + * The repositories with the same id will share one client and cookie jar. + */ + abstract val scopeId: String +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/AAORepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/AAORepository.kt new file mode 100644 index 0000000..45c4381 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/AAORepository.kt @@ -0,0 +1,41 @@ +package com.fduhole.danxi.repository.fdu + +import com.fduhole.danxi.model.fdu.AAONotice +import com.fduhole.danxi.repository.settings.SettingsRepository +import io.ktor.client.call.body +import io.ktor.http.Url +import kotlinx.coroutines.Dispatchers +import org.jsoup.Jsoup +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AAORepository @Inject constructor( + settingsRepository: SettingsRepository, +) : BaseFDURepository(settingsRepository) { + companion object { + const val TYPE_NOTICE_ANNOUNCEMENT = "9397" + private val LOGIN_URL = + Url("https://uis.fudan.edu.cn/authserver/login?service=http%3A%2F%2Fjwc.fudan.edu.cn%2Feb%2Fb7%2Fc9397a388023%2Fpage.psp") + private const val HOST = "https://jwc.fudan.edu.cn" + } + + override fun getUISLoginUrl() = LOGIN_URL + + override val scopeId = "fudan.edu.cn" + + private fun getNoticeListUrl(type: String, page: Int): String = "$HOST/$type/list${if (page <= 1) "" else page}.htm" + + suspend fun getNoticeList(page: Int, type: String = TYPE_NOTICE_ANNOUNCEMENT): List = with(Dispatchers.IO) { + val res: String = client.getUIS(getNoticeListUrl(type, page)).body() + val doc = Jsoup.parse(res) + doc.select(".wp_article_list_table > tbody > tr > td > table > tbody").map { + val noticeInfo = it.select("tr").select("td") + AAONotice( + title = noticeInfo[0].text().trim(), + url = HOST + noticeInfo[0].select("a").attr("href"), + time = noticeInfo[1].text().trim() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/BaseFDURepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/BaseFDURepository.kt new file mode 100644 index 0000000..5a2c08a --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/BaseFDURepository.kt @@ -0,0 +1,176 @@ +package com.fduhole.danxi.repository.fdu + +import androidx.annotation.StringRes +import com.fduhole.danxi.R +import com.fduhole.danxi.repository.BaseRepository +import com.fduhole.danxi.repository.settings.SettingsRepository +import com.fduhole.danxi.util.net.MemoryCookiesStorage +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.cookies.CookiesStorage +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.forms.FormDataContent +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.request +import io.ktor.http.HttpMethod +import io.ktor.http.Parameters +import io.ktor.http.Url +import io.ktor.http.parameters +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jsoup.Jsoup + +abstract class BaseFDURepository( + val settingsRepository: SettingsRepository, +) : BaseRepository() { + + abstract fun getUISLoginUrl(): Url + + open val maxRetryTimes = 2 + + companion object { + val uisLoginMutex = Mutex() + const val UIS_HOST = "uis.fudan.edu.cn" + + sealed class UISLoginException(@StringRes val id: Int, message: String) : Throwable(message) { + data object WeakPassword : UISLoginException(R.string.weak_password_note, "week_password") + data object UnderMaintenance : UISLoginException(R.string.under_maintenance_note, "under_maintenance") + data object Unknown : UISLoginException(R.string.uis_login_unknown_note, "unknown") + data object NeedCaptcha : UISLoginException(R.string.need_captcha_note, "need_captcha") + data object InvalidCredentials : UISLoginException(R.string.invalid_credentials_note, "invalid_credentials") + } + + private val uisMessageExceptionMap = mapOf( + "请输入验证码" to UISLoginException.NeedCaptcha, + "密码有误" to UISLoginException.InvalidCredentials, + "弱密码提示" to UISLoginException.WeakPassword, + "网络维护中 | Under Maintenance" to UISLoginException.UnderMaintenance, + ) + + suspend fun login( + id: String, + password: String, + loginUrl: Url, + ): CookiesStorage { + // 创建临时的 cookiesStorage,用于登录 + val tmpCookiesStorage = MemoryCookiesStorage() + // 创建临时的 HttpClient,用于登录 + val tmpClient = createTmpClient(tmpCookiesStorage) + + // 获取登录页面 + val res = tmpClient.get(loginUrl) + val doc = Jsoup.parse(res.body()) + + // 登录 + var response = tmpClient.submitForm( + loginUrl.toString(), + formParameters = parameters { + for (element in doc.select("input")) { + if (element.attr("type") != "button" && element.attr("name") != "username" && element.attr("name") != "password") { + append(element.attr("name"), element.attr("value")) + } + } + append("username", id) + append("password", password) + } + ) + + // force redirect, see https://github.com/ktorio/ktor/issues/1623 + while (response.headers.contains("Location")) { + val location = response.headers["Location"]!! + response = tmpClient.get(location) + } + + val body = response.body() + uisMessageExceptionMap.forEach { (message, exception) -> + if (body.contains(message)) { + throw exception + } + } + return tmpCookiesStorage + } + } + + suspend inline fun HttpClient.getUIS( + url: Url, + block: HttpRequestBuilder.() -> Unit = {}, + ): HttpResponse = getUIS { + url(url) + block() + } + + suspend inline fun HttpClient.getUIS( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {}, + ): HttpResponse = getUIS { + url(urlString) + block() + } + + suspend inline fun HttpClient.getUIS(block: HttpRequestBuilder.() -> Unit) = + getUIS(HttpRequestBuilder().apply(block)) + + suspend inline fun HttpClient.getUIS(builder: HttpRequestBuilder): HttpResponse { + builder.method = HttpMethod.Get + return requestUIS(builder) + } + + suspend inline fun HttpClient.submitFormUIS( + url: Url, + formParameters: Parameters = Parameters.Empty, + encodeInQuery: Boolean = false, + block: HttpRequestBuilder.() -> Unit = {} + ): HttpResponse = submitFormUIS(formParameters, encodeInQuery) { + url(url) + block() + } + + suspend inline fun HttpClient.submitFormUIS( + formParameters: Parameters = Parameters.Empty, + encodeInQuery: Boolean = false, + block: HttpRequestBuilder.() -> Unit = {} + ): HttpResponse = requestUIS { + if (encodeInQuery) { + method = HttpMethod.Get + url.parameters.appendAll(formParameters) + } else { + method = HttpMethod.Post + setBody(FormDataContent(formParameters)) + } + + block() + } + + suspend inline fun HttpClient.requestUIS( + block: HttpRequestBuilder.() -> Unit = {}, + ) = requestUIS(HttpRequestBuilder().apply(block)) + + suspend inline fun HttpClient.requestUIS(builder: HttpRequestBuilder): HttpResponse { + var response = request(builder) // process request with redirect + repeat(maxRetryTimes) { _ -> + if (builder.url.host.contains(UIS_HOST)) + return response + if (response.request.url.host.contains(UIS_HOST)) { + uisLoginMutex.withLock { + val uisInfo = settingsRepository.uisInfo.get() ?: throw UISLoginException.Unknown + cookiesStorage.replaceBy( + login( + id = uisInfo.id, + password = uisInfo.password, + loginUrl = getUISLoginUrl(), + ) + ) + } + response = request(builder) + } else { + return response + } + } + throw UISLoginException.Unknown + } +} diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/ECardRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/ECardRepository.kt new file mode 100644 index 0000000..9145083 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/ECardRepository.kt @@ -0,0 +1,170 @@ +package com.fduhole.danxi.repository.fdu + +import com.fduhole.danxi.model.fdu.CardPersonInfo +import com.fduhole.danxi.model.fdu.CardRecord +import com.fduhole.danxi.repository.settings.SettingsRepository +import com.fduhole.danxi.util.appendAll +import com.fduhole.danxi.util.between +import com.fduhole.danxi.util.toDateTimeString +import io.ktor.client.call.body +import io.ktor.http.HttpHeaders +import io.ktor.http.Url +import io.ktor.http.headers +import io.ktor.http.parameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import org.jsoup.Jsoup +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.days + +@Singleton +class ECardRepository @Inject constructor( + settingsRepository: SettingsRepository +) : BaseFDURepository(settingsRepository) { + override fun getUISLoginUrl() = LOGIN_URL + override val scopeId: String = "fudan.edu.cn" + + companion object { + private val LOGIN_URL = + Url("https://uis.fudan.edu.cn/authserver/login?service=https%3A%2F%2Fecard.fudan.edu.cn%2Fepay%2Fj_spring_cas_security_check") + val USER_DETAIL_URL = Url("https://ecard.fudan.edu.cn/epay/myepay/index") + val CONSUME_DETAIL_URL = Url("https://ecard.fudan.edu.cn/epay/consume/query") + val CONSUME_DETAIL_CSRF_URL = Url("https://ecard.fudan.edu.cn/epay/consume/index") + private val CONSUME_DETAIL_HEADER = mapOf( + HttpHeaders.UserAgent to "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0", + HttpHeaders.Accept to "text/xml", + HttpHeaders.AcceptLanguage to "zh-CN,en-US;q=0.7,en;q=0.3", + HttpHeaders.ContentType to "application/x-www-form-urlencoded", + HttpHeaders.Origin to "https://ecard.fudan.edu.cn", + "DNT" to "1", + HttpHeaders.Connection to "keep-alive", + HttpHeaders.Referrer to "https://ecard.fudan.edu.cn/epay/consume/index", + "Sec-GPC" to "1" + ) + val QR_URL = Url("https://ecard.fudan.edu.cn/epay/wxpage/fudan/zfm/qrcode") + } + + /** + * Get personal info (and card info). + */ + suspend fun getCardPersonInfo(): CardPersonInfo = withContext(Dispatchers.IO) { + val body: String = client.getUIS(USER_DETAIL_URL).body() + val soup = Jsoup.parse(body) + val balance = requireNotNull(soup.selectFirst(".payway-box-bottom-item > p")?.text()) { "balance cannot be null." } + val name = requireNotNull(soup.selectFirst(".custname")?.text()?.between("您好,", "!")) { "name cannot be null." } + val recentRecord = soup.select(".tab-content > #all tbody tr").map { + val details = requireNotNull(it.getElementsByTag("td")) { "td element cannot be null." } + val dateStr = details[0].child(0).text().replace(".", "-") + val timeStr = details[0].child(1).text().trim() + CardRecord( + time = LocalDateTime.parse("${dateStr}T${timeStr}").toInstant(TimeZone.currentSystemDefault()), + type = details[1].child(0).text().trim(), + location = details[2].text().trim().replace(" ", "").trim(), + amount = details[3].text().trim().replace(" ", "").trim(), + balance = details[4].text().trim().replace(" ", "").trim(), + ) + } + CardPersonInfo(balance, name, recentRecord) + } + + /** + * Get the card records of last [dayNum] days. + * + * If [dayNum] = 0, it returns the last ten records; + * if [dayNum] < 0, it throws an error. + */ + suspend fun getCardRecords(dayNum: Int): List = withContext(Dispatchers.IO) { + val payloadAndPageNum = getPagedCardRecordsPayloadAndPageNum(dayNum) + val list = arrayListOf() + for (i in 1..payloadAndPageNum.second) { + list.addAll(getPagedCardRecords(payloadAndPageNum.first, i)) + } + list + } + + suspend fun getPagedCardRecords(payload: Map, pageIndex: Int): List = withContext(Dispatchers.IO) { + requireNotNull(loadOnePageCardRecord(payload, pageIndex)) { "Got null data at page index $pageIndex" } + } + + suspend fun getPagedCardRecordsPayloadAndPageNum(dayNum: Int): Pair, Int> { + require(dayNum >= 0) { "Day number should not be less than 0." } + val consumeCsrfPage: String = client.getUIS(CONSUME_DETAIL_CSRF_URL).body() + val consumeCsrfPageSoup = Jsoup.parse(consumeCsrfPage) + val metas = consumeCsrfPageSoup.getElementsByTag("meta") + val element = metas.find { e -> e.attr("name") == "_csrf" } + val csrfId = requireNotNull(element?.attr("content")) { "CSRF id is null." } + + // Build the request body. + val end = Clock.System.now() + val backDays = if (dayNum == 0) 180 else dayNum + val start = end.minus(backDays.days) + val datePattern = "yyyy-MM-dd" + val payload = mapOf( + "aaxmlrequest" to "true", + "pageNo" to "1", + "tabNo" to "1", + "pager.offset" to "10", + "tradename" to "", + "starttime" to start.toDateTimeString(datePattern), + "endtime" to end.toDateTimeString(datePattern), + "timetype" to "1", + "_tradedirect" to "on", + "_csrf" to csrfId, + ) + // Get the number of pages, only when logDays > 0. + var totalPages = 1 + if (dayNum > 0) { + val detailBody: String = client.submitFormUIS( + CONSUME_DETAIL_URL, + formParameters = parameters { + appendAll(payload) + }, + ).body() + totalPages = detailBody.between("/", "页")?.toIntOrNull() ?: 0 + } + return payload to totalPages + } + + suspend fun getQRCode(): String = withContext(Dispatchers.IO) { + val body: String = client.getUIS(QR_URL).body() + val soup = Jsoup.parse(body) + val result = soup.selectFirst("#myText")?.attr("value") + requireNotNull(result) { "Cannot find `value` in the response page." } + } + + private suspend fun loadOnePageCardRecord(payload: Map, pageIndex: Int): List? = withContext(Dispatchers.IO) { + val requestData = payload.toMutableMap() + requestData["pageNo"] = pageIndex.toString() + val detailBody: String = client.submitFormUIS( + CONSUME_DETAIL_URL, + formParameters = parameters { + appendAll(requestData) + }, + ) { + headers { + CONSUME_DETAIL_HEADER.forEach { (k, v) -> + append(k, v) + } + } + }.body() + val soup = detailBody.between("")?.let { Jsoup.parse(it) } + val elements = soup?.selectFirst("tbody")?.getElementsByTag("tr") + elements?.map { + val details = requireNotNull(it.getElementsByTag("td")) { "td element cannot be null." } + val dateStr = details[0].child(0).text().replace(".", "-") + val timeStr = details[0].child(1).text().trim() + CardRecord( + time = LocalDateTime.parse("${dateStr}T${timeStr}").toInstant(TimeZone.currentSystemDefault()), + type = details[1].child(0).text().trim(), + location = details[2].text().trim().replace(" ", ""), + amount = details[3].text().trim().replace(" ", ""), + balance = details[4].text().trim().replace(" ", "").trim(), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/EhallRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/EhallRepository.kt new file mode 100644 index 0000000..5abe918 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/EhallRepository.kt @@ -0,0 +1,39 @@ +package com.fduhole.danxi.repository.fdu + +import com.fduhole.danxi.model.fdu.EhallStudentInfo +import com.fduhole.danxi.repository.settings.SettingsRepository +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.Url +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class EhallRepository @Inject constructor( + settingsRepository: SettingsRepository +) : BaseFDURepository(settingsRepository) { + companion object { + private val INFO_URL = + Url("https://ehall.fudan.edu.cn/jsonp/ywtb/info/getUserInfoAndSchoolInfo.json") + private val LOGIN_URL = + Url("https://uis.fudan.edu.cn/authserver/login?service=http%3A%2F%2Fehall.fudan.edu.cn%2Flogin%3Fservice%3Dhttp%3A%2F%2Fehall.fudan.edu.cn%2Fywtb-portal%2Ffudan%2Findex.html") + } + + override fun getUISLoginUrl() = LOGIN_URL + + override val scopeId = "ehall.fudan.edu.cn" + + suspend fun getStudentInfo(id: String, password: String): EhallStudentInfo = withContext(Dispatchers.IO) { + // Execute manual logging in. + // Login and absorb the auth cookie. + val cookiesStorage = login(id, password, LOGIN_URL) + val tmpClient = createTmpClient(cookiesStorage) + val body: String = tmpClient.get(INFO_URL).body() + val obj = JSONObject(body).getJSONObject("data") + EhallStudentInfo(obj.optString("userName"), obj.optString("userTypeName"), obj.optString("userDepartment")) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/fdu/LibraryRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/fdu/LibraryRepository.kt new file mode 100644 index 0000000..ebe4a10 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/fdu/LibraryRepository.kt @@ -0,0 +1,50 @@ +package com.fduhole.danxi.repository.fdu + +import com.fduhole.danxi.model.fdu.LibraryInfo +import com.fduhole.danxi.repository.BaseRepository +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.post +import io.ktor.http.Url +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LibraryRepository @Inject constructor() : BaseRepository() { + companion object { + private val INFO_URL = Url("https://mlibrary.fudan.edu.cn/api/common/h5/getspaceseat") + } + + override val scopeId = "mlibrary.fudan.edu.cn" + + @Serializable + private data class AttendanceResponse( + val msg: String, + val code: String, + val data: List, + ) + + @OptIn(ExperimentalSerializationApi::class) + override fun createClient() = createTmpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + explicitNulls = false + }) + } + } + + /** + * @return a list whose size is 6. + * The sequence is 文科馆、医科馆B1、江湾馆、张江馆、医科馆1-6层、理科馆 + */ + suspend fun getAttendance(): List = withContext(Dispatchers.IO) { + client.post(INFO_URL).body().data + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleApiService.kt b/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleApiService.kt new file mode 100644 index 0000000..ee3684b --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleApiService.kt @@ -0,0 +1,55 @@ +package com.fduhole.danxi.repository.opentreehole + +import com.fduhole.danxi.model.opentreehole.OTDivision +import com.fduhole.danxi.model.opentreehole.OTFloor +import com.fduhole.danxi.model.opentreehole.OTHole +import com.fduhole.danxi.model.opentreehole.OTNewHole +import com.fduhole.danxi.model.opentreehole.OTTag +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query + + +interface FDUHoleApiService { + + @GET("divisions") + suspend fun getDivisions(): List + + + @GET("divisions/{division_id}/holes") + suspend fun getHoles( + @Path("division_id") divisionId: Int, + @Query("offset") offsetOrId: String?, + @Query("size") size: Int?, + ): List + + @GET("holes/{id}") + suspend fun getHole( + @Path("id") holeId: Int, + ): OTHole + + @GET("holes/{hole_id}/floors") + suspend fun getFloors( + @Path("hole_id") holeId: Int, + @Query("offset") offset: Int?, + @Query("order_by") orderBy: String?, + @Query("size") size: Int?, + @Query("sort") sort: String?, + ): List + + @GET("floors/{id}") + suspend fun getFloor( + @Path("id") id: Int, + ): OTFloor + + @GET("tags") + suspend fun getTags(): List + + @POST("divisions/{division_id}/holes") + suspend fun postHole( + @Path("division_id") divisionId: Int, @Body json: OTNewHole, + ): OTHole + +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleAuthApiService.kt b/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleAuthApiService.kt new file mode 100644 index 0000000..349e4ec --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleAuthApiService.kt @@ -0,0 +1,34 @@ +package com.fduhole.danxi.repository.opentreehole + +import com.fduhole.danxi.model.opentreehole.OTJWTToken +import com.fduhole.danxi.model.opentreehole.OTLoginInfo +import com.fduhole.danxi.model.opentreehole.OTRegisterInfo +import com.fduhole.danxi.model.opentreehole.OTVerifyCode +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Query +import io.ktor.client.statement.HttpResponse + + +interface FDUHoleAuthApiService { + @GET("verify/apikey") + suspend fun getRegisterStatus( + @Query("apikey") apiKey: String, + @Query("email") email: String, + @Query("check_register") checkRegister: Int = 1 + ): HttpResponse + + @GET("verify/apikey") + suspend fun getVerifyCode(@Query("apikey") apiKey: String, @Query("email") email: String): OTVerifyCode + + @GET("verify/email") + suspend fun requestEmailVerifyCode(@Query("email") email: String): HttpResponse + + @POST("register") + suspend fun register(@Body registerInfo: OTRegisterInfo): OTJWTToken + + @POST("login") + suspend fun login(@Body loginInfo: OTLoginInfo): OTJWTToken + +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleRepository.kt new file mode 100644 index 0000000..c44139d --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/opentreehole/FDUHoleRepository.kt @@ -0,0 +1,42 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.fduhole.danxi.repository.opentreehole + +import com.fduhole.danxi.model.opentreehole.OTJWTToken +import com.fduhole.danxi.model.opentreehole.OTLoginInfo +import com.fduhole.danxi.repository.BaseRepository +import de.jensklingenberg.ktorfit.Ktorfit +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import javax.inject.Inject + +// Configure the JSON (de)serializer to match APIs' need better. +private val jsonConfig = Json { + // Do not throw an exception when deserializing an object with unknown keys. + ignoreUnknownKeys = true + // Do not encode a field into the final json string if it is null. + explicitNulls = false +} + +class FDUHoleRepository @Inject constructor() : BaseRepository() { + companion object { + const val BASE_URL = "https://hole.hath.top/api/" + const val BASE_AUTH_URL = "https://testauth.hath.top/api/" + } + + private val authApiService: FDUHoleAuthApiService = + Ktorfit.Builder().httpClient(client).baseUrl(BASE_AUTH_URL).build().create() + + override fun createClient() = createTmpClient { + install(ContentNegotiation) { + json(jsonConfig) + } + } + + + override val scopeId = "fduhole.com" + + suspend fun login(email: String, password: String): OTJWTToken = authApiService.login(OTLoginInfo(password, email)) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/settings/SettingsRepository.kt b/app/src/main/java/com/fduhole/danxi/repository/settings/SettingsRepository.kt new file mode 100644 index 0000000..124337a --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/settings/SettingsRepository.kt @@ -0,0 +1,32 @@ +package com.fduhole.danxi.repository.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.fduhole.danxi.model.fdu.UISInfo +import com.fduhole.danxi.model.opentreehole.OTJWTToken +import com.fduhole.danxi.repository.settings.basic.jsonItem +import com.fduhole.danxi.repository.settings.basic.settingsItem +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SettingsRepository @Inject constructor( + dataStore: DataStore, +) { + // high contrast color + private val keyHighContrastColor = booleanPreferencesKey("high_contrast_color") + val highContrastColor = settingsItem(keyHighContrastColor, false, dataStore) + + // dark mode + private val keyDarkTheme = booleanPreferencesKey("dark_theme") + val darkTheme = settingsItem(keyDarkTheme, null, dataStore) + + /* secret items */ + private val keyFDUUISInfo = stringPreferencesKey("fdu_uis_info") + val uisInfo = jsonItem(settingsItem(keyFDUUISInfo, null, dataStore)) + + private val keyFDUHoleInfo = stringPreferencesKey("fduhole_token") + val fduHoleToken = jsonItem(settingsItem(keyFDUHoleInfo, null, dataStore)) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/settings/basic/DataStoreItem.kt b/app/src/main/java/com/fduhole/danxi/repository/settings/basic/DataStoreItem.kt new file mode 100644 index 0000000..ebf1f3a --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/settings/basic/DataStoreItem.kt @@ -0,0 +1,17 @@ +package com.fduhole.danxi.repository.settings.basic + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** + * A simple data store item + * @see DataStoreItem from BIT101 + */ +interface DataStoreItem { + suspend fun get() = withContext(Dispatchers.IO) { flow.first() } + suspend fun set(value: T?) + suspend fun remove() = set(null) + val flow: Flow +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/settings/basic/JsonItem.kt b/app/src/main/java/com/fduhole/danxi/repository/settings/basic/JsonItem.kt new file mode 100644 index 0000000..7bae595 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/settings/basic/JsonItem.kt @@ -0,0 +1,30 @@ +package com.fduhole.danxi.repository.settings.basic + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +inline fun jsonItem(parentItem: DataStoreItem): DataStoreItem { + return object : DataStoreItem { + override val flow: Flow + get() = parentItem.flow.map { + try { + it?.let { + Json.decodeFromString(it) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + override suspend fun set(value: T?) = withContext(Dispatchers.IO) { + parentItem.set(value?.let { + Json.encodeToString(it) + }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/repository/settings/basic/SettingsItem.kt b/app/src/main/java/com/fduhole/danxi/repository/settings/basic/SettingsItem.kt new file mode 100644 index 0000000..2941e35 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/repository/settings/basic/SettingsItem.kt @@ -0,0 +1,33 @@ +package com.fduhole.danxi.repository.settings.basic + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +fun settingsItem( + key: Preferences.Key, + initialValue: T?, + dataStore: DataStore, +): DataStoreItem { + return object : DataStoreItem { + override suspend fun set(value: T?) = withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + if (value != null) { + preferences[key] = value + } else { + preferences.remove(key) + } + } + return@withContext + } + + override val flow: Flow + get() = dataStore.data.map { preferences -> + preferences[key] ?: initialValue + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/DanXiNavigation.kt b/app/src/main/java/com/fduhole/danxi/ui/DanXiNavigation.kt new file mode 100644 index 0000000..48a54a2 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/DanXiNavigation.kt @@ -0,0 +1,79 @@ +package com.fduhole.danxi.ui + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.fduhole.danxi.ui.page.MainPage +import com.fduhole.danxi.ui.page.common.WebViewModel +import com.fduhole.danxi.ui.page.common.WebViewPage +import com.fduhole.danxi.ui.page.fdu.AAONoticesPage +import com.fduhole.danxi.ui.page.fdu.FudanECardPage + +object DanXiDestinations { + const val MAIN = "main" + const val CARD_DETAIL = "card_detail" + const val AAO_NOTICE = "aao_notice" + const val WEB_VIEW = "web_view" +} + +@Composable +fun DanXiNavGraph( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), + globalViewModel: GlobalViewModel = viewModel(), + startDestination: String = DanXiDestinations.MAIN, +) { + val canNavigateUpCheck = { navController.previousBackStackEntry != null } + val navigateUp: () -> Unit = { navController.navigateUp() } + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + enterTransition = { fadeIn(animationSpec = tween(300)) }, + exitTransition = { fadeOut(animationSpec = tween(300)) }, + ) { + composable(DanXiDestinations.MAIN) { + MainPage( + navController = navController, + globalViewModel = globalViewModel, + ) + } + composable(DanXiDestinations.CARD_DETAIL) { + FudanECardPage( + fudanECardFeatureStateHolder = globalViewModel.fudanStateHolder.fudanECardFeature, + canNavigateUp = canNavigateUpCheck(), + navigateUp = navigateUp, + ) + } + composable(DanXiDestinations.AAO_NOTICE) { + AAONoticesPage( + navController = navController, + canNavigateUp = canNavigateUpCheck(), + navigateUp = navigateUp, + ) + } + composable(DanXiDestinations.WEB_VIEW) { + val parentEntry = remember { navController.previousBackStackEntry } + val viewModel: WebViewModel = hiltViewModel(parentEntry ?: it) + if (viewModel.url != null) { + WebViewPage( + url = viewModel.url!!, + javascript = viewModel.javascript, + feature = viewModel.feature, + ) + } else { + navController.navigateUp() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/GlobalViewModel.kt b/app/src/main/java/com/fduhole/danxi/ui/GlobalViewModel.kt new file mode 100644 index 0000000..4089e23 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/GlobalViewModel.kt @@ -0,0 +1,80 @@ +package com.fduhole.danxi.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fduhole.danxi.model.opentreehole.OTJWTToken +import com.fduhole.danxi.repository.settings.SettingsRepository +import com.fduhole.danxi.ui.component.fdu.FudanStateHolder +import com.fduhole.danxi.util.LoginStatus +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class GlobalViewState( + val isDarkTheme: Boolean? = null, + val highContrastColor: Boolean = false, +) + +data class FDUHoleViewState( + val token: OTJWTToken, + val id: Int, +) + +@HiltViewModel +class GlobalViewModel @Inject constructor( + val settingsRepository: SettingsRepository, + val fudanStateHolder: FudanStateHolder, +) : ViewModel() { + private val _fduHoleState = + MutableStateFlow>(LoginStatus.NotLogin) + + val uiState = settingsRepository.run { + combine( + darkTheme.flow, + highContrastColor.flow, + ) { darkTheme, highContrastColor -> + GlobalViewState( + isDarkTheme = darkTheme, + highContrastColor = highContrastColor ?: false, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, GlobalViewState()) + } + val fduHoleState = _fduHoleState.asStateFlow() + + var settingsExpanded by mutableStateOf(false) + var aboutExpanded by mutableStateOf(false) + + // scroll state + val dashboardScrollState = ScrollState(0) + val fduHoleScrollState = ScrollState(0) + val courseScrollState = ScrollState(0) + val timetableScrollState = ScrollState(0) + val settingsScrollState = ScrollState(0) + + private val stateHolders = listOf( + fudanStateHolder, + ) + + init { + // bind StateHolders scope and start coroutine + stateHolders.forEach { + it.scope = viewModelScope + it.start() + } + } + + fun setDarkTheme(isDarkTheme: Boolean?) { + viewModelScope.launch { + settingsRepository.darkTheme.set(isDarkTheme) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/RequestPermission.kt b/app/src/main/java/com/fduhole/danxi/ui/component/RequestPermission.kt new file mode 100644 index 0000000..f2f5909 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/RequestPermission.kt @@ -0,0 +1,66 @@ +package com.fduhole.danxi.ui.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.res.stringResource +import com.fduhole.danxi.R +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun RequestSinglePermissionDialog( + permission: String, + rationale: String, + callback: (Boolean) -> Unit = {}, + onDismissRequest: () -> Unit = {} +) { + val permissionState = rememberPermissionState(permission) { + callback(it) + } + if (!permissionState.status.isGranted) { + if (permissionState.status.shouldShowRationale) { + AlertDialog( + onDismissRequest = { + onDismissRequest() + callback(false) + }, + confirmButton = { + TextButton( + onClick = { + permissionState.launchPermissionRequest() + onDismissRequest() + }, + ) { + Text(stringResource(R.string.accept)) + } + }, + dismissButton = { + TextButton( + onClick = { + callback(false) + onDismissRequest() + }, + ) { + Text(stringResource(R.string.deny)) + } + }, + text = { + Text(rationale) + }, + ) + } else { + LaunchedEffect(Unit) { + permissionState.launchPermissionRequest() + } + } + } else { + callback(true) + onDismissRequest() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/fdu/Feature.kt b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/Feature.kt new file mode 100644 index 0000000..54f966c --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/Feature.kt @@ -0,0 +1,102 @@ +package com.fduhole.danxi.ui.component.fdu + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavController +import com.fduhole.danxi.util.StateHolder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +abstract class Feature( + val icon: ImageVector, + @get:StringRes val title: Int, + val subTitleDefault: String? = null, + val shouldLoadData: Boolean = true, + val hasMoreContent: Boolean = false, + val shouldNavigateOnClick: Boolean = false, +) : StateHolder() { + + sealed interface Status { + data object Idle : Status + data object Loading : Status + data class Error(val error: Throwable) : Status + data class Success( + val message: String, + val data: T, + ) : Status + } + + data class State( + val clickable: Boolean, + val state: Status = Status.Idle, + val showMoreContent: Boolean = false, + ) + + protected abstract val mUIState: MutableStateFlow> + val uiState: StateFlow> + get() = mUIState.asStateFlow() + + open fun onStart(navController: NavController) {} + + open fun onRefresh(navController: NavController) {} + + open suspend fun loading() { + if (uiState.value.state !is Status.Loading) { + mUIState.update { it.copy(state = Status.Loading, clickable = false) } + loadData().onSuccess { newState -> + mUIState.update { it.copy(state = newState, clickable = true) } + }.onFailure { e -> + mUIState.update { it.copy(state = Status.Error(e), clickable = true) } + } + } + } + + open fun onClick(navController: NavController) { + scope.launch { + val showMoreContent: () -> Unit = { + mUIState.update { it.copy(showMoreContent = true) } + } + + when (mUIState.value.state) { + Status.Idle -> { + if (shouldLoadData) { + loading() + } else { + if (hasMoreContent) { + showMoreContent() + } else if (shouldNavigateOnClick) { + navigate(navController) + } else { + loading() + } + } + } + + is Status.Error -> { + if (shouldLoadData) loading() + } + + is Status.Success -> { + if (hasMoreContent) { + showMoreContent() + } else if (shouldNavigateOnClick) { + navigate(navController) + } else { + loading() + } + } + + else -> {} + } + } + } + + open suspend fun loadData(): Result> = Result.failure(NotImplementedError()) + open val trailingContent: @Composable () -> Unit = {} + open val moreContent: @Composable (navController: NavController) -> Unit = {} + open fun navigate(navController: NavController) {} +} diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/fdu/FudanStateHolder.kt b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/FudanStateHolder.kt new file mode 100644 index 0000000..2aec767 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/FudanStateHolder.kt @@ -0,0 +1,77 @@ +package com.fduhole.danxi.ui.component.fdu + +import com.fduhole.danxi.model.fdu.UISInfo +import com.fduhole.danxi.repository.fdu.EhallRepository +import com.fduhole.danxi.repository.settings.SettingsRepository +import com.fduhole.danxi.ui.component.fdu.feature.AAONoticesFeature +import com.fduhole.danxi.ui.component.fdu.feature.ECardFeature +import com.fduhole.danxi.ui.component.fdu.feature.LibraryAttendanceFeature +import com.fduhole.danxi.ui.component.fdu.feature.QRCodeFeature +import com.fduhole.danxi.util.LoginStatus +import com.fduhole.danxi.util.StateHolder +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ViewModelScoped +class FudanStateHolder @Inject constructor( + private val settingsRepository: SettingsRepository, + private val ehallRepository: EhallRepository, + + val fudanECardFeature: ECardFeature, + aaoNoticesFeature: AAONoticesFeature, + libraryAttendanceFeature: LibraryAttendanceFeature, + qrCodeFeature: QRCodeFeature, +) : StateHolder() { + + val features = listOf>( + fudanECardFeature, + aaoNoticesFeature, + libraryAttendanceFeature, + qrCodeFeature, + ) + + private val _fduUISState = MutableStateFlow>(LoginStatus.NotLogin) + val fduState = _fduUISState.asStateFlow() + + override fun start() { + features.forEach { + it.scope = scope + it.start() + } + scope.launch { + settingsRepository.uisInfo.flow.collect { info -> + if (info == null) { + if (_fduUISState.value !is LoginStatus.Error) { + _fduUISState.update { LoginStatus.NotLogin } + } + } else { + if (_fduUISState.value is LoginStatus.NotLogin) { + loginFDUUIS(info.id, info.password) + } + } + } + } + } + + fun loginFDUUIS(id: String, password: String) { + // If error exists, do not login + if (id.isEmpty() || password.isEmpty()) return + + _fduUISState.update { LoginStatus.Loading } + scope.launch { + try { + val studentInfo = ehallRepository.getStudentInfo(id, password) + val uisInfo = UISInfo(id, password, studentInfo.name) + settingsRepository.uisInfo.set(uisInfo) + _fduUISState.update { LoginStatus.Success(uisInfo) } + } catch (e: Throwable) { + settingsRepository.uisInfo.remove() + _fduUISState.update { LoginStatus.Error(e) } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/AAONoticesFeature.kt b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/AAONoticesFeature.kt new file mode 100644 index 0000000..570ade7 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/AAONoticesFeature.kt @@ -0,0 +1,28 @@ +package com.fduhole.danxi.ui.component.fdu.feature + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Newspaper +import androidx.navigation.NavController +import com.fduhole.danxi.R +import com.fduhole.danxi.ui.DanXiDestinations +import com.fduhole.danxi.ui.component.fdu.Feature +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + + +@ViewModelScoped +class AAONoticesFeature @Inject constructor() : Feature( + icon = Icons.Filled.Newspaper, + title = R.string.fudan_aao_notices, + shouldLoadData = false, + shouldNavigateOnClick = true, +) { + override val mUIState = MutableStateFlow( + State(clickable = true) + ) + + override fun navigate(navController: NavController) { + navController.navigate(DanXiDestinations.AAO_NOTICE) + } +} diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/ECardFeature.kt b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/ECardFeature.kt new file mode 100644 index 0000000..ec3e9ac --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/ECardFeature.kt @@ -0,0 +1,100 @@ +package com.fduhole.danxi.ui.component.fdu.feature + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.fduhole.danxi.R +import com.fduhole.danxi.model.fdu.CardPersonInfo +import com.fduhole.danxi.model.fdu.CardRecord +import com.fduhole.danxi.repository.fdu.ECardRepository +import com.fduhole.danxi.ui.DanXiDestinations +import com.fduhole.danxi.ui.component.fdu.Feature +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ViewModelScoped +class ECardFeature @Inject constructor( + val repository: ECardRepository, +) : Feature( + icon = Icons.Filled.CreditCard, + title = R.string.fudan_ecard_balance, + shouldNavigateOnClick = true, +) { + override val mUIState = MutableStateFlow( + State(clickable = true) + ) + + override fun onStart(navController: NavController) { + scope.launch { loading() } + } + + override fun onRefresh(navController: NavController) { + scope.launch { loading() } + } + + override suspend fun loadData(): Result> = runCatching { + val info = repository.getCardPersonInfo() + Status.Success( + message = info.recentRecord.firstOrNull()?.let { "¥${it.amount} ${it.location}" } ?: "无消费记录", + data = info, + ) + } + + override val trailingContent: @Composable () -> Unit = { + val state by uiState.collectAsStateWithLifecycle() + when (state.state) { + Status.Loading -> CircularProgressIndicator() + is Status.Success -> { + Text("¥${(state.state as Status.Success).data.balance}") + } + + else -> {} + } + } + + val lazyPagingCardRecords by lazy { + Pager(PagingConfig(pageSize = 10)) { ECardPageSource(repository) }.flow + } + + override fun navigate(navController: NavController) { + navController.navigate(DanXiDestinations.CARD_DETAIL) + } +} + +class ECardPageSource( + private val repository: ECardRepository, +) : PagingSource() { + private val maxDays: Int = 365 + private var payload: Map? = null + private var totalPageNum: Int? = null + override fun getRefreshKey(state: PagingState): Int? = null + + override suspend fun load(params: LoadParams): LoadResult = try { + if (payload == null || totalPageNum == null) { + val info = repository.getPagedCardRecordsPayloadAndPageNum(maxDays) + payload = info.first + totalPageNum = info.second + } + val pageNumber = params.key ?: 1 + val response = repository.getPagedCardRecords(payload!!, pageNumber) + LoadResult.Page( + response, + nextKey = if (pageNumber + 1 > (totalPageNum ?: 0)) null else pageNumber + 1, + prevKey = null, + ) + } catch (e: Throwable) { + LoadResult.Error(e) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/LibraryAttendanceFeature.kt b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/LibraryAttendanceFeature.kt new file mode 100644 index 0000000..7305d32 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/LibraryAttendanceFeature.kt @@ -0,0 +1,66 @@ +package com.fduhole.danxi.ui.component.fdu.feature + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocalLibrary +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.fduhole.danxi.R +import com.fduhole.danxi.model.fdu.LibraryInfo +import com.fduhole.danxi.repository.fdu.LibraryRepository +import com.fduhole.danxi.ui.component.fdu.Feature +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + + +@ViewModelScoped +class LibraryAttendanceFeature @Inject constructor( + val repository: LibraryRepository, +) : Feature>( + icon = Icons.Filled.LocalLibrary, + title = R.string.fudan_library_attendance, + hasMoreContent = true, +) { + override val mUIState = MutableStateFlow( + State>(clickable = true) + ) + + override suspend fun loadData() = runCatching { + val attendanceList = repository.getAttendance() + Status.Success( + message = attendanceList.fold(StringBuilder()) { builder, info -> + builder.append("${info.campusName}: ${info.inNum} ") + }.toString(), + data = attendanceList, + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + override val moreContent: @Composable (navController: NavController) -> Unit = { + ModalBottomSheet( + onDismissRequest = { mUIState.update { it.copy(showMoreContent = false) } }, + ) { + val state by uiState.collectAsStateWithLifecycle() + val supportingText = if (state.state is Status.Success) { + (state.state as Status.Success>) + .data.fold(StringBuilder()) { builder, info -> + builder.append("${info.campusName}: ${info.inNum}\n") + }.toString() + } else { + "" + } + ListItem( + headlineContent = { Text(stringResource(id = title)) }, + supportingContent = { Text(supportingText) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/QRCodeFeature.kt b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/QRCodeFeature.kt new file mode 100644 index 0000000..bb7e6f1 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/fdu/feature/QRCodeFeature.kt @@ -0,0 +1,147 @@ +package com.fduhole.danxi.ui.component.fdu.feature + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.fduhole.danxi.R +import com.fduhole.danxi.repository.fdu.ECardRepository +import com.fduhole.danxi.ui.component.fdu.Feature +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.MultiFormatWriter +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ViewModelScoped +class QRCodeFeature @Inject constructor( + val repository: ECardRepository +) : Feature( + icon = Icons.Filled.QrCode, + title = R.string.fudan_qr_code, + hasMoreContent = true, +) { + override val mUIState = MutableStateFlow( + State(clickable = true) + ) + + override fun onClick(navController: NavController) { + when (mUIState.value.state) { + Status.Idle -> { + mUIState.update { it.copy(showMoreContent = true, clickable = false) } + } + + is Status.Success, is Status.Error -> { + mUIState.update { it.copy(state = Status.Idle, showMoreContent = false, clickable = true) } + } + + else -> {} + } + } + + override val moreContent: @Composable (navController: NavController) -> Unit = { + var qrCodeBitMap by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val task: suspend CoroutineScope.() -> Unit = { + try { + qrCodeBitMap = generateQRCode(repository.getQRCode()) + } catch (e: Throwable) { + error = e + } + } + var job: Job? = null + LaunchedEffect(Unit) { + job = scope.launch(context = Dispatchers.IO, block = task) + } + + val title = stringResource(title) + AlertDialog( + onDismissRequest = { mUIState.update { it.copy(showMoreContent = false, clickable = true) } }, + confirmButton = { + TextButton( + onClick = { + mUIState.update { it.copy(showMoreContent = false, clickable = true) } + }, + ) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + if (error != null) { + TextButton(onClick = { + job?.cancel() + }) { + Text(stringResource(R.string.retry)) + } + } + }, + title = { Text(title) }, + text = { + if (qrCodeBitMap != null) { + Image( + qrCodeBitMap!!.asImageBitmap(), + contentDescription = title, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } else { + if (error != null) { + Text(stringResource(R.string.failed_to_load)) + } else { + Text(stringResource(R.string.loading_qr_code)) + } + } + }, + ) + } + + private fun generateQRCode(data: String): Bitmap { + val matrix = MultiFormatWriter().encode( + data, BarcodeFormat.QR_CODE, 200, 200, mutableMapOf( + EncodeHintType.CHARACTER_SET to "utf-8", + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L, + EncodeHintType.MARGIN to 2 + ) + ) + return toBitmap(matrix) + } + + private fun toBitmap(bitMatrix: BitMatrix): Bitmap { + val width = bitMatrix.width + val height = bitMatrix.height + val pixels = IntArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + pixels[y * width + x] = if (bitMatrix[x, y]) -0x1000000 else -0x1 + } + } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + return bitmap + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/settings/AboutCard.kt b/app/src/main/java/com/fduhole/danxi/ui/component/settings/AboutCard.kt new file mode 100644 index 0000000..43c18f6 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/settings/AboutCard.kt @@ -0,0 +1,159 @@ +package com.fduhole.danxi.ui.component.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.fduhole.danxi.BuildConfig +import com.fduhole.danxi.R + +data class Developer( + @DrawableRes val avatar: Int, + val name: String, + val role: String, + val url: String, +) + +val developers = listOf( + Developer(R.drawable.w568w, "w568w", "Android 主要开发者", "https://github.com/w568w"), + Developer(R.drawable.skyleaworld, "skyleaworlder", "Android 主要开发者", "https://github.com/skyleaworlder"), + Developer(R.drawable.fsy2001, "fsy2001", "iOS 主要开发者", "https://github.com/fsy2001"), + Developer(R.drawable.kavinzhao, "singularity", "iOS 主要开发者", "https://github.com/singularity-s0"), + Developer(R.drawable.ivanfei, "Ivan Fei", "App 图标 & UI 设计", "https://github.com/ivanfei-1"), +) + +data class License( + val name: String, + val author: String, + val license: String, + val url: String, +) { + companion object { + const val MIT = "MIT License" + const val APACHE_2 = "Apache Software License 2.0" + const val GPL_V3 = "GNU general public license Version 3" + } +} + +val licenses = listOf( + License("kotlinx.serialization", "Kotlin", License.APACHE_2, "https://github.com/Kotlin/kotlinx.serialization"), + License("kotlinx-datetime", "Kotlin", License.APACHE_2, "https://github.com/Kotlin/kotlinx-datetime"), + License("jsoup", "jhy", License.MIT, "https://github.com/jhy/jsoup"), + License("Android Jetpack", "google", License.MIT, "https://maven.google.com/"), + License("junit4", "junit-team", "Eclipse Public License 1.0", "https://github.com/junit-team/junit4"), +) + +fun openUrl(context: Context, url: String) { + val urlIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse(url) + ) + context.startActivity(urlIntent) +} + +@Composable +fun AboutCard( + expanded: Boolean, + onChangeExpanded: () -> Unit, +) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + ) { + val text = stringResource(id = R.string.about) + ListItem( + headlineContent = { + Text(text) + }, + leadingContent = { + Icon(Icons.Filled.Info, contentDescription = text) + }, + trailingContent = { + val icon = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore + Icon(icon, contentDescription = text) + }, + modifier = Modifier.clickable { + onChangeExpanded() + }, + ) + + if (expanded) { + AboutContent() + } + } +} + +@Composable +private fun AboutContent() { + Column { + ListItem( + headlineContent = { Text(stringResource(R.string.app_description_title)) }, + supportingContent = { Text(stringResource(R.string.app_description)) } + ) + ListItem( + headlineContent = { Text(stringResource(id = R.string.version)) }, + supportingContent = { Text("${BuildConfig.VERSION_NAME} (Build ${BuildConfig.VERSION_CODE})") } + ) + HorizontalDivider() + ListItem(headlineContent = { Text(text = stringResource(id = R.string.developers)) }) + Column { + developers.forEach { + val context = LocalContext.current + ListItem( + leadingContent = { + Image( + painterResource(it.avatar), + contentDescription = it.name, + modifier = Modifier + .size(30.dp) + .clip(CircleShape), + ) + }, + headlineContent = { Text(it.name) }, + supportingContent = { Text(it.role) }, + modifier = Modifier.clickable { + openUrl(context, it.url) + } + ) + } + } + HorizontalDivider() + ListItem(headlineContent = { Text(text = stringResource(id = R.string.license)) }) + Column { + licenses.forEach { + val context = LocalContext.current + ListItem( + headlineContent = { Text(it.name) }, + supportingContent = { Text("${it.author} - ${it.license}") }, + modifier = Modifier.clickable { + openUrl(context, it.url) + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/settings/FDUHoleLoginItem.kt b/app/src/main/java/com/fduhole/danxi/ui/component/settings/FDUHoleLoginItem.kt new file mode 100644 index 0000000..f95eb95 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/settings/FDUHoleLoginItem.kt @@ -0,0 +1,70 @@ +package com.fduhole.danxi.ui.component.settings + +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.fduhole.danxi.ui.FDUHoleViewState +import com.fduhole.danxi.util.LoginStatus + +@Composable +fun FDUHoleLoginItem(fduHoleState: LoginStatus) { + ListItem( + headlineContent = { + Text("FDUHole 账号") + }, + supportingContent = { + when (fduHoleState) { + is LoginStatus.Error -> { + Text("登录失败") + } + + LoginStatus.Loading -> { + Text("登录中") + } + + LoginStatus.NotLogin -> { + Text("未登录") + } + + is LoginStatus.Success -> { + val id = fduHoleState.data.id + Text("登录成功, id: $id") + } + } + }, + leadingContent = { + Icon(Icons.Filled.AccountCircle, contentDescription = "FDUHole 账号") + }, + trailingContent = { + when (fduHoleState) { + is LoginStatus.Error -> { + Icon(Icons.Filled.Error, contentDescription = "FDUHole 账号 登录失败") + } + + LoginStatus.Loading -> { + CircularProgressIndicator() + } + + LoginStatus.NotLogin -> { + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "FDUHole 账号 登录") + } + + is LoginStatus.Success -> { + Icon(Icons.Filled.Check, contentDescription = "FDUHole 账号 登录成功") + } + } + }, + modifier = Modifier.clickable { + + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/settings/FDUUISLoginItem.kt b/app/src/main/java/com/fduhole/danxi/ui/component/settings/FDUUISLoginItem.kt new file mode 100644 index 0000000..31f3849 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/settings/FDUUISLoginItem.kt @@ -0,0 +1,102 @@ +package com.fduhole.danxi.ui.component.settings + +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.fduhole.danxi.model.fdu.UISInfo +import com.fduhole.danxi.repository.fdu.BaseFDURepository +import com.fduhole.danxi.ui.component.fdu.FudanStateHolder +import com.fduhole.danxi.util.LoginStatus + +@Composable +fun FDUUISLoginItem( + fduState: LoginStatus, + fudanStateHolder: FudanStateHolder, +) { + var showUISLoginDialog by rememberSaveable { mutableStateOf(false) } + + ListItem( + headlineContent = { + Text("复旦 UIS 账号") + }, + supportingContent = { + when (fduState) { + is LoginStatus.Error -> { + Text("登录失败") + } + + LoginStatus.Loading -> { + Text("登录中") + } + + LoginStatus.NotLogin -> { + Text("未登录") + } + + is LoginStatus.Success -> { + val name = fduState.data.name + val id = fduState.data.id + Text("已登录 - $name($id)") + } + } + }, + leadingContent = { + Icon(Icons.Filled.AccountCircle, contentDescription = "复旦 UIS 账号") + }, + trailingContent = { + when (fduState) { + is LoginStatus.Error -> { + Icon(Icons.Filled.Error, contentDescription = "复旦 UIS 账号 - 登录失败") + } + + LoginStatus.Loading -> { + CircularProgressIndicator() + } + + LoginStatus.NotLogin -> { + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "复旦 UIS 账号 - 登录") + } + + is LoginStatus.Success -> { + Icon(Icons.Filled.Check, contentDescription = "复旦 UIS 账号 - 登录成功") + } + } + }, + modifier = Modifier.clickable { + showUISLoginDialog = true + }, + ) + + if (showUISLoginDialog) { + val errorMessage = if (fduState is LoginStatus.Error) { + val error = fduState.error + if (error is BaseFDURepository.Companion.UISLoginException) { + stringResource(id = error.id) + } else { + error.message ?: "" + } + } else "" + UISLoginDialog( + onDismissRequest = { showUISLoginDialog = false }, + onLogin = { id, password -> + fudanStateHolder.loginFDUUIS(id, password) + }, + enabled = fduState !is LoginStatus.Loading, + errorMessage = errorMessage, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/settings/SettingsCard.kt b/app/src/main/java/com/fduhole/danxi/ui/component/settings/SettingsCard.kt new file mode 100644 index 0000000..3acbe83 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/settings/SettingsCard.kt @@ -0,0 +1,190 @@ +package com.fduhole.danxi.ui.component.settings + +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Brightness4 +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.core.os.LocaleListCompat +import com.fduhole.danxi.R +import com.fduhole.danxi.ui.GlobalViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsCard( + globalViewModel: GlobalViewModel +) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState() + var showThemeBottomSheet by rememberSaveable { mutableStateOf(false) } + var showLanguageBottomSheet by rememberSaveable { mutableStateOf(false) } + val expanded = globalViewModel.settingsExpanded + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + ) { + val settingsText = stringResource(id = R.string.settings) + ListItem( + headlineContent = { + Text(settingsText) + }, + leadingContent = { + Icon(Icons.Filled.Settings, contentDescription = settingsText) + }, + trailingContent = { + val icon = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore + Icon(icon, contentDescription = settingsText) + }, + modifier = Modifier.clickable { + globalViewModel.settingsExpanded = !expanded + }, + ) + + if (expanded) { + val languageText = stringResource(id = R.string.language) + ListItem( + headlineContent = { + Text(languageText) + }, + leadingContent = { + Icon(Icons.Filled.Language, contentDescription = languageText) + }, + modifier = Modifier.clickable { + showLanguageBottomSheet = true + }, + ) + + val themeText = stringResource(id = R.string.theme) + ListItem( + headlineContent = { + Text(themeText) + }, + leadingContent = { + Icon(Icons.Filled.Brightness4, contentDescription = themeText) + }, + modifier = Modifier.clickable { + showThemeBottomSheet = true + }, + ) + } + } + + if (showThemeBottomSheet) { + ThemeBottomSheet( + onDismissRequest = { showThemeBottomSheet = false }, + themeSheetState = sheetState, + scope = scope, + setDarkTheme = globalViewModel::setDarkTheme, + ) + } + + if (showLanguageBottomSheet) { + LanguageBottomSheet( + scope = scope, + onDismissRequest = { showLanguageBottomSheet = false }, + themeSheetState = sheetState, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThemeBottomSheet( + onDismissRequest: () -> Unit, + themeSheetState: SheetState, + scope: CoroutineScope, + setDarkTheme: (Boolean?) -> Unit, +) { + val themeOptions = mapOf( + R.string.light to false, + R.string.dark to true, + R.string.follow_system to null, + ) + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = themeSheetState, + ) { + val hide = { + scope.launch { + themeSheetState.hide() + onDismissRequest() + }.invokeOnCompletion { + if (!themeSheetState.isVisible) { + onDismissRequest() + } + } + } + themeOptions.forEach { (resId, darkTheme) -> + ListItem( + headlineContent = { Text(stringResource(resId)) }, + modifier = Modifier.clickable { + hide() + setDarkTheme(darkTheme) + }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanguageBottomSheet( + scope: CoroutineScope, + onDismissRequest: () -> Unit, + themeSheetState: SheetState, +) { + val localeOptions = mapOf( + R.string.en to "en", + R.string.zh to "zh", + ) + val hide = { + scope.launch { + themeSheetState.hide() + onDismissRequest() + }.invokeOnCompletion { + if (!themeSheetState.isVisible) { + onDismissRequest() + } + } + } + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = themeSheetState, + ) { + localeOptions.forEach { (resId, locale) -> + ListItem( + headlineContent = { Text(stringResource(resId)) }, + modifier = Modifier.clickable { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(locale) + ) + hide() + }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/component/settings/UISLoginDialog.kt b/app/src/main/java/com/fduhole/danxi/ui/component/settings/UISLoginDialog.kt new file mode 100644 index 0000000..cd16eff --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/component/settings/UISLoginDialog.kt @@ -0,0 +1,88 @@ +package com.fduhole.danxi.ui.component.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.fduhole.danxi.R + +@Composable +fun UISLoginDialog( + onDismissRequest: () -> Unit, + onLogin: (String, String) -> Unit, + enabled: Boolean, + errorMessage: String = "", +) { + var id by rememberSaveable { mutableStateOf("") } + var isIdError by rememberSaveable { mutableStateOf(false) } + var password by rememberSaveable { mutableStateOf("") } + var isPasswordError by rememberSaveable { mutableStateOf(false) } + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { if (!isIdError && !isPasswordError) onLogin(id, password) }, + enabled = enabled && id.isNotEmpty() && password.isNotEmpty(), + ) { + Text(stringResource(id = R.string.login)) + } + }, + dismissButton = { + TextButton( + onClick = { onDismissRequest() }, + ) { + Text(stringResource(id = R.string.cancel)) + } + }, + title = { Text(stringResource(id = R.string.login_uis)) }, + text = { + Column { + OutlinedTextField( + value = id, + onValueChange = { + isIdError = it.isEmpty() + id = it + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(id = R.string.id)) }, + enabled = enabled, + isError = isIdError, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { + isPasswordError = it.isEmpty() + password = it + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(id = R.string.password)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + enabled = enabled, + isError = isPasswordError, + ) + if (errorMessage.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text(errorMessage, color = MaterialTheme.colorScheme.error) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/CourseSubpage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/CourseSubpage.kt new file mode 100644 index 0000000..6e486cb --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/CourseSubpage.kt @@ -0,0 +1,20 @@ +package com.fduhole.danxi.ui.page + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EggAlt +import androidx.compose.runtime.Composable +import com.fduhole.danxi.R +import com.fduhole.danxi.ui.GlobalViewModel + +class CourseSubpage( + globalViewModel: GlobalViewModel, +) : Subpage { + override val title = R.string.course + override val icon = Icons.Filled.EggAlt + override val scrollState = globalViewModel.courseScrollState + override val body: @Composable BoxScope.() -> Unit = {} + override val leading: @Composable () -> Unit = {} + override val trailing: @Composable RowScope.() -> Unit = {} +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/DashboardSubpage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/DashboardSubpage.kt new file mode 100644 index 0000000..0dc5391 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/DashboardSubpage.kt @@ -0,0 +1,114 @@ +package com.fduhole.danxi.ui.page + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.fduhole.danxi.R +import com.fduhole.danxi.ui.GlobalViewModel +import com.fduhole.danxi.ui.component.fdu.Feature +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +class DashboardSubpage( + navController: NavController, + globalViewModel: GlobalViewModel, +) : Subpage { + override val title = R.string.dashboard + override val icon = Icons.Filled.Home + override val scrollState = globalViewModel.dashboardScrollState + + @OptIn(ExperimentalMaterial3Api::class) + override val body: @Composable BoxScope.() -> Unit = { + val featureStateHolders = globalViewModel.fudanStateHolder.features + val pullToRefreshState = rememberPullToRefreshState() + val haptics = LocalHapticFeedback.current + if (pullToRefreshState.isRefreshing) { + LaunchedEffect(Unit) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + delay(0.25.seconds) + pullToRefreshState.endRefresh() + } + } + + Box( + Modifier + .nestedScroll(pullToRefreshState.nestedScrollConnection) + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Column( + modifier = Modifier.padding(8.dp) + ) { + ElevatedCard { + featureStateHolders.forEach { + val state by it.uiState.collectAsStateWithLifecycle() + + // load data on start + if (state.state is Feature.Status.Idle && it.shouldLoadData) { + it.onStart(navController) + } + + if (pullToRefreshState.isRefreshing) { + LaunchedEffect(Unit) { + it.onRefresh(navController) + } + } + + val title = stringResource(id = it.title) + val subTitle: String = it.subTitleDefault ?: when (state.state) { + Feature.Status.Idle -> stringResource(id = R.string.tap_to_view) + Feature.Status.Loading -> stringResource(id = R.string.loading) + is Feature.Status.Error -> stringResource(id = R.string.failed_to_load) + is Feature.Status.Success -> (state.state as Feature.Status.Success).message + } + ListItem( + headlineContent = { Text(title) }, + supportingContent = { Text(subTitle) }, + leadingContent = { Icon(it.icon, contentDescription = title) }, + trailingContent = it.trailingContent, + modifier = Modifier.clickable(enabled = state.clickable) { + it.onClick(navController) + } + ) + + if (state.showMoreContent) { + it.moreContent(navController) + } + } + } + } + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + override val leading: @Composable () -> Unit = {} + override val trailing: @Composable RowScope.() -> Unit = {} +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/FDUHoleSubpage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/FDUHoleSubpage.kt new file mode 100644 index 0000000..1112089 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/FDUHoleSubpage.kt @@ -0,0 +1,20 @@ +package com.fduhole.danxi.ui.page + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forum +import androidx.compose.runtime.Composable +import com.fduhole.danxi.R +import com.fduhole.danxi.ui.GlobalViewModel + +class FDUHoleSubpage( + globalViewModel: GlobalViewModel +) : Subpage { + override val title = R.string.fduhole + override val icon = Icons.Filled.Forum + override val scrollState = globalViewModel.fduHoleScrollState + override val body: @Composable BoxScope.() -> Unit = {} + override val leading: @Composable () -> Unit = {} + override val trailing: @Composable RowScope.() -> Unit = {} +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/MainPage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/MainPage.kt new file mode 100644 index 0000000..9b39925 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/MainPage.kt @@ -0,0 +1,99 @@ +package com.fduhole.danxi.ui.page + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.fduhole.danxi.ui.GlobalViewModel +import com.fduhole.danxi.util.LoginStatus + +@Composable +fun MainPage( + navController: NavController, + globalViewModel: GlobalViewModel, +) { + val fduState by globalViewModel.fudanStateHolder.fduState.collectAsStateWithLifecycle() + val fduHoleState by globalViewModel.fduHoleState.collectAsStateWithLifecycle() + val subpages = buildList { + if (fduState is LoginStatus.Success) { + add( + DashboardSubpage( + navController = navController, + globalViewModel = globalViewModel, + ) + ) + } + if (fduHoleState is LoginStatus.Success) { + add(FDUHoleSubpage(globalViewModel)) + add(CourseSubpage(globalViewModel)) + } + if (fduState is LoginStatus.Success) { + add(TimetableSubpage(globalViewModel)) + } + add( + SettingsSubpage( + navController = navController, + globalViewModel = globalViewModel, + ) + ) + } + + MainPageContent(subpages) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainPageContent(subpages: List) { + var currentPage by rememberSaveable { mutableIntStateOf(0) } + if (currentPage >= subpages.size) { + currentPage = 0 + } + val selectedSubpage = subpages[currentPage] + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = selectedSubpage.title)) }, + navigationIcon = selectedSubpage.leading, + actions = selectedSubpage.trailing, + ) + }, + bottomBar = { + if (subpages.size <= 1) return@Scaffold + NavigationBar { + subpages.forEachIndexed { index, subpage -> + val titleText = stringResource(id = subpage.title) + NavigationBarItem( + icon = { Icon(subpage.icon, contentDescription = titleText) }, + label = { Text(titleText) }, + selected = currentPage == index, + onClick = { + currentPage = index + }, + ) + } + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + content = selectedSubpage.body, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/SettingsSubpage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/SettingsSubpage.kt new file mode 100644 index 0000000..0bda41e --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/SettingsSubpage.kt @@ -0,0 +1,64 @@ +package com.fduhole.danxi.ui.page + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ElevatedCard +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.fduhole.danxi.R +import com.fduhole.danxi.ui.GlobalViewModel +import com.fduhole.danxi.ui.component.settings.AboutCard +import com.fduhole.danxi.ui.component.settings.FDUHoleLoginItem +import com.fduhole.danxi.ui.component.settings.FDUUISLoginItem +import com.fduhole.danxi.ui.component.settings.SettingsCard + +class SettingsSubpage( + private val navController: NavController, + private val globalViewModel: GlobalViewModel, +) : Subpage { + override val title = R.string.settings + override val icon = Icons.Filled.Settings + + override val scrollState = globalViewModel.settingsScrollState + + override val body: @Composable BoxScope.() -> Unit = { + val fduState by globalViewModel.fudanStateHolder.fduState.collectAsStateWithLifecycle() + val fduHoleState by globalViewModel.fduHoleState.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(8.dp) + .fillMaxSize(), + ) { + ElevatedCard { + FDUUISLoginItem(fduState, globalViewModel.fudanStateHolder) + FDUHoleLoginItem(fduHoleState) + } + Spacer(modifier = Modifier.padding(8.dp)) + SettingsCard( + globalViewModel = globalViewModel, + ) + Spacer(modifier = Modifier.padding(8.dp)) + AboutCard( + expanded = globalViewModel.aboutExpanded, + onChangeExpanded = { globalViewModel.aboutExpanded = !globalViewModel.aboutExpanded }, + ) + } + } + + + override val leading: @Composable () -> Unit = {} + override val trailing: @Composable RowScope.() -> Unit = {} +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/Subpage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/Subpage.kt new file mode 100644 index 0000000..87e5dcd --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/Subpage.kt @@ -0,0 +1,18 @@ +package com.fduhole.danxi.ui.page + +import androidx.annotation.StringRes +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector + +interface Subpage { + @get:StringRes + val title: Int + val icon: ImageVector + val scrollState: ScrollState + val leading: @Composable () -> Unit + val trailing: @Composable RowScope.() -> Unit + val body: @Composable BoxScope.() -> Unit +} diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/TimetableSubpage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/TimetableSubpage.kt new file mode 100644 index 0000000..ff43009 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/TimetableSubpage.kt @@ -0,0 +1,20 @@ +package com.fduhole.danxi.ui.page + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.runtime.Composable +import com.fduhole.danxi.R +import com.fduhole.danxi.ui.GlobalViewModel + +class TimetableSubpage( + globalViewModel: GlobalViewModel +) : Subpage { + override val title = R.string.timetable + override val icon = Icons.Filled.CalendarToday + override val scrollState = globalViewModel.timetableScrollState + override val body: @Composable BoxScope.() -> Unit = {} + override val leading: @Composable () -> Unit = {} + override val trailing: @Composable RowScope.() -> Unit = {} +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/common/LazyPagingColumn.kt b/app/src/main/java/com/fduhole/danxi/ui/page/common/LazyPagingColumn.kt new file mode 100644 index 0000000..7ea5459 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/common/LazyPagingColumn.kt @@ -0,0 +1,84 @@ +package com.fduhole.danxi.ui.page.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import kotlinx.coroutines.flow.Flow + +@Composable +fun LazyPagingColumn( + lazyPagingFlow: Flow>, + key: (T) -> Any, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, +) { + val lazyPagingData = lazyPagingFlow.collectAsLazyPagingItems() + + LaunchedEffect(Unit) { + lazyPagingData.refresh() + } + + LazyColumn( + modifier = modifier + ) { + items( + count = lazyPagingData.itemCount, + key = lazyPagingData.itemKey { key(it) } + ) { index -> + lazyPagingData[index]?.let { + content(it) + } + } + + when (val refreshLoadState = lazyPagingData.loadState.refresh) { + LoadState.Loading -> item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } + } + + is LoadState.Error -> item { + val error = refreshLoadState.error + ListItem(headlineContent = { + Text(error.toString(), color = MaterialTheme.colorScheme.error) + }) + } + + else -> {} + } + when (val appendLoadState = lazyPagingData.loadState.append) { + LoadState.Loading -> item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } + } + + is LoadState.Error -> item { + val error = appendLoadState.error + ListItem(headlineContent = { + Text(error.toString(), color = MaterialTheme.colorScheme.error) + }) + } + + else -> {} + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/common/NavigationScaffold.kt b/app/src/main/java/com/fduhole/danxi/ui/page/common/NavigationScaffold.kt new file mode 100644 index 0000000..beb0306 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/common/NavigationScaffold.kt @@ -0,0 +1,64 @@ +package com.fduhole.danxi.ui.page.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NavigationScaffold( + title: String, + canNavigateUp: Boolean = false, + navigateUp: (() -> Unit) = {}, + actions: @Composable RowScope.() -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + content: @Composable BoxScope.() -> Unit, +) { + val canNavigateUpInner = remember { canNavigateUp } + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + if (canNavigateUpInner) { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回", + ) + } + } + }, + actions = actions, + ) + }, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + content = content, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/common/WebViewPage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/common/WebViewPage.kt new file mode 100644 index 0000000..3c9666f --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/common/WebViewPage.kt @@ -0,0 +1,103 @@ +package com.fduhole.danxi.ui.page.common + +import android.Manifest +import android.webkit.GeolocationPermissions +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fduhole.danxi.R +import com.fduhole.danxi.model.fdu.UISInfo +import com.fduhole.danxi.repository.settings.SettingsRepository +import com.fduhole.danxi.ui.component.RequestSinglePermissionDialog +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class WebViewModel @Inject constructor( + val settingsRepository: SettingsRepository +) : ViewModel() { + var url: String? = null + var javascript: String? = null + var feature: String? = null + + var uisInfo: StateFlow = + settingsRepository.uisInfo.flow.stateIn(viewModelScope, SharingStarted.Eagerly, null) +} + +@Composable +fun WebViewPage( + url: String, + javascript: String?, + feature: String?, +) { + val scope = rememberCoroutineScope() + var showPermissionDialog by remember { mutableStateOf(false) } + var permissionCallback: ((Boolean) -> Unit)? by remember { mutableStateOf(null) } + + if (showPermissionDialog) { + RequestSinglePermissionDialog( + permission = Manifest.permission.ACCESS_COARSE_LOCATION, + rationale = stringResource(R.string.location_permission_rationale), + callback = permissionCallback!!, + onDismissRequest = { showPermissionDialog = false } + ) + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + useWideViewPort = true + setSupportZoom(true) + builtInZoomControls = true + } + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (url != null && feature != null && url.startsWith(feature)) { + scope.launch { + delay(1.seconds) + view?.evaluateJavascript(javascript.orEmpty()) {} + } + } + } + } + + webChromeClient = object : WebChromeClient() { + override fun onGeolocationPermissionsShowPrompt(origin: String?, callback: GeolocationPermissions.Callback?) { + super.onGeolocationPermissionsShowPrompt(origin, callback) + permissionCallback = { + callback?.invoke(origin, it, it) + showPermissionDialog = false + } + } + } + } + }, + update = { webView -> + webView.loadUrl(url) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/fdu/AAONoticesPage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/fdu/AAONoticesPage.kt new file mode 100644 index 0000000..a73d794 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/fdu/AAONoticesPage.kt @@ -0,0 +1,64 @@ +package com.fduhole.danxi.ui.page.fdu + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.fduhole.danxi.R +import com.fduhole.danxi.model.fdu.AAONotice +import com.fduhole.danxi.ui.DanXiDestinations +import com.fduhole.danxi.ui.page.common.LazyPagingColumn +import com.fduhole.danxi.ui.page.common.NavigationScaffold +import com.fduhole.danxi.ui.page.common.WebViewModel +import com.fduhole.danxi.util.uisLoginJavaScript + +@Composable +fun AAONoticesPage( + navController: NavController, + viewModel: AAONoticesViewModel = hiltViewModel(), + canNavigateUp: Boolean = false, + navigateUp: () -> Unit = {}, +) { + NavigationScaffold( + title = stringResource(R.string.fudan_aao_notices), + canNavigateUp = canNavigateUp, + navigateUp = navigateUp, + ) { + LazyPagingColumn( + lazyPagingFlow = viewModel.pagingData, + key = { it.url }, + modifier = Modifier.padding(8.dp) + ) { + AAONoticeItem(it, navController = navController) + } + } +} + +@Composable +fun AAONoticeItem( + notice: AAONotice, + navController: NavController, +) { + val webViewModel: WebViewModel = hiltViewModel() + val uisInfo by webViewModel.uisInfo.collectAsStateWithLifecycle() + ListItem( + headlineContent = { Text(notice.title) }, + supportingContent = { Text(notice.time) }, + modifier = Modifier.clickable { + webViewModel.apply { + url = notice.url + javascript = uisInfo?.let { uisLoginJavaScript(it) } + feature = "https://uis.fudan.edu.cn/authserver/login" + } + navController.navigate(DanXiDestinations.WEB_VIEW) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/fdu/AAONoticesViewModel.kt b/app/src/main/java/com/fduhole/danxi/ui/page/fdu/AAONoticesViewModel.kt new file mode 100644 index 0000000..d2c1930 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/fdu/AAONoticesViewModel.kt @@ -0,0 +1,37 @@ +package com.fduhole.danxi.ui.page.fdu + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.cachedIn +import com.fduhole.danxi.model.fdu.AAONotice +import com.fduhole.danxi.repository.fdu.AAORepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class AAONoticesViewModel @Inject constructor( + private val repository: AAORepository, +) : ViewModel() { + val pagingData by lazy { + Pager(PagingConfig(pageSize = 10)) { AAONoticePageSource(repository) } + .flow.cachedIn(viewModelScope) + } +} + +class AAONoticePageSource( + private val repository: AAORepository, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null + + override suspend fun load(params: LoadParams): LoadResult = try { + val pageNumber = params.key ?: 1 + val response = repository.getNoticeList(pageNumber) + LoadResult.Page(response, nextKey = if (response.isEmpty()) null else pageNumber + 1, prevKey = null) + } catch (e: Throwable) { + LoadResult.Error(e) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/page/fdu/ECardPage.kt b/app/src/main/java/com/fduhole/danxi/ui/page/fdu/ECardPage.kt new file mode 100644 index 0000000..367cc9e --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/page/fdu/ECardPage.kt @@ -0,0 +1,44 @@ +package com.fduhole.danxi.ui.page.fdu + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.fduhole.danxi.R +import com.fduhole.danxi.model.fdu.CardRecord +import com.fduhole.danxi.ui.component.fdu.feature.ECardFeature +import com.fduhole.danxi.ui.page.common.LazyPagingColumn +import com.fduhole.danxi.ui.page.common.NavigationScaffold + +@Composable +fun FudanECardPage( + fudanECardFeatureStateHolder: ECardFeature, + canNavigateUp: Boolean = false, + navigateUp: () -> Unit = {}, +) { + NavigationScaffold( + title = stringResource(R.string.fudan_ecard_balance), + canNavigateUp = canNavigateUp, + navigateUp = navigateUp, + ) { + LazyPagingColumn( + lazyPagingFlow = fudanECardFeatureStateHolder.lazyPagingCardRecords, + key = { "${it.time}-${it.balance}" }, + modifier = Modifier.padding(8.dp) + ) { + ECardItem(it) + } + } +} + +@Composable +fun ECardItem(record: CardRecord) { + ListItem( + headlineContent = { Text(record.location) }, + supportingContent = { Text(record.time.toString()) }, + trailingContent = { Text("¥${record.amount}") } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/ui/theme/Themes.kt b/app/src/main/java/com/fduhole/danxi/ui/theme/Themes.kt new file mode 100644 index 0000000..af234cc --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/ui/theme/Themes.kt @@ -0,0 +1,38 @@ +package com.fduhole.danxi.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun DanXiNativeTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + isDynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val dynamicColor = isDynamicColor && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S + val colorScheme = when { + dynamicColor && isDarkTheme -> { + dynamicDarkColorScheme(LocalContext.current) + } + + dynamicColor && !isDarkTheme -> { + dynamicLightColorScheme(LocalContext.current) + } + + isDarkTheme -> darkColorScheme() + else -> lightColorScheme() + } + val typography = Typography() + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/util/Extensions.kt b/app/src/main/java/com/fduhole/danxi/util/Extensions.kt similarity index 80% rename from app/src/main/java/com/fduhole/danxinative/util/Extensions.kt rename to app/src/main/java/com/fduhole/danxi/util/Extensions.kt index bb85be7..76a0aee 100644 --- a/app/src/main/java/com/fduhole/danxinative/util/Extensions.kt +++ b/app/src/main/java/com/fduhole/danxi/util/Extensions.kt @@ -1,10 +1,10 @@ -package com.fduhole.danxinative.util +package com.fduhole.danxi.util +import io.ktor.http.ParametersBuilder import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toLocalDateTime -import okhttp3.FormBody import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -23,14 +23,10 @@ fun String.between(start: String, end: String, headGreedy: Boolean = true): Stri } } -fun FormBody.Builder.addMap(map: Map, alreadyEncoded: Boolean = false): FormBody.Builder { +fun ParametersBuilder.appendAll(map: Map) { for (entry in map) { - if (alreadyEncoded) - addEncoded(entry.key, entry.value) - else - add(entry.key, entry.value) + append(entry.key, entry.value) } - return this } fun Instant.toDateTimeString(formatter: DateTimeFormatter): String = diff --git a/app/src/main/java/com/fduhole/danxinative/util/FDULoginUtils.kt b/app/src/main/java/com/fduhole/danxi/util/FDULoginUtils.kt similarity index 53% rename from app/src/main/java/com/fduhole/danxinative/util/FDULoginUtils.kt rename to app/src/main/java/com/fduhole/danxi/util/FDULoginUtils.kt index b85be54..fe0a0dd 100644 --- a/app/src/main/java/com/fduhole/danxinative/util/FDULoginUtils.kt +++ b/app/src/main/java/com/fduhole/danxi/util/FDULoginUtils.kt @@ -1,31 +1,27 @@ -package com.fduhole.danxinative.util +package com.fduhole.danxi.util -import com.fduhole.danxinative.model.PersonInfo +import com.fduhole.danxi.model.fdu.UISInfo -class FDULoginUtils { - companion object { - fun uisLoginJavaScript(info: PersonInfo): String = """try{ +fun uisLoginJavaScript(info: UISInfo): String = """try{ document.getElementById('username').value = String.raw`""" + - info.id + - """`; + info.id + + """`; document.getElementById('password').value = String.raw`""" + - info.password + - """`; + info.password + + """`; document.forms[0].submit(); } catch (e) { try{ document.getElementById('mobileUsername').value = String.raw`""" + - info.id + - """`; + info.id + + """`; document.getElementById('mobilePassword').value = String.raw`""" + - info.password + - """`; + info.password + + """`; document.forms[0].submit(); } catch (e) { window.alert("DanXi: Failed to auto login UIS"); } }""" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/util/LoginStatus.kt b/app/src/main/java/com/fduhole/danxi/util/LoginStatus.kt new file mode 100644 index 0000000..ab50c68 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/util/LoginStatus.kt @@ -0,0 +1,8 @@ +package com.fduhole.danxi.util + +sealed class LoginStatus { + data object NotLogin : LoginStatus() + data object Loading : LoginStatus() + data class Success(val data: T) : LoginStatus() + data class Error(val error: Throwable) : LoginStatus() +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/util/StateHolder.kt b/app/src/main/java/com/fduhole/danxi/util/StateHolder.kt new file mode 100644 index 0000000..512ef63 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/util/StateHolder.kt @@ -0,0 +1,11 @@ +package com.fduhole.danxi.util + +import kotlinx.coroutines.CoroutineScope + +/** + * hold UI State, launch startup coroutines by a [androidx.lifecycle.ViewModel] + */ +open class StateHolder { + lateinit var scope: CoroutineScope + open fun start() {} +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxi/util/net/MemoryCookiesStorage.kt b/app/src/main/java/com/fduhole/danxi/util/net/MemoryCookiesStorage.kt new file mode 100644 index 0000000..7d28535 --- /dev/null +++ b/app/src/main/java/com/fduhole/danxi/util/net/MemoryCookiesStorage.kt @@ -0,0 +1,20 @@ +package com.fduhole.danxi.util.net + +import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage +import io.ktor.client.plugins.cookies.CookiesStorage +import io.ktor.http.Cookie +import io.ktor.http.Url + +open class MemoryCookiesStorage( + private var storage: CookiesStorage = AcceptAllCookiesStorage(), +) : CookiesStorage { + override suspend fun addCookie(requestUrl: Url, cookie: Cookie) = storage.addCookie(requestUrl, cookie) + + override fun close() = storage.close() + + override suspend fun get(requestUrl: Url) = storage.get(requestUrl) + + fun replaceBy(storage: CookiesStorage) { + this.storage = storage + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/AboutActivity.kt b/app/src/main/java/com/fduhole/danxinative/AboutActivity.kt deleted file mode 100644 index e9a7e85..0000000 --- a/app/src/main/java/com/fduhole/danxinative/AboutActivity.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.fduhole.danxinative - -import android.widget.ImageView -import android.widget.TextView -import com.drakeet.about.* - -class AboutActivity : AbsAboutActivity() { - override fun onCreateHeader(icon: ImageView, slogan: TextView, version: TextView) { - icon.setImageResource(R.mipmap.ic_launcher) - slogan.text = getString(R.string.app_name) - version.text = "${BuildConfig.VERSION_NAME} (Build ${BuildConfig.VERSION_CODE})" - } - - override fun onItemsCreated(items: MutableList) { - items.addAll( - listOf( - Category(getString(R.string.app_description_title)), - Card(getString(R.string.app_description)), - Category(getString(R.string.developers)), - Contributor(R.drawable.w568w, "w568w", "Android 主要开发者", "https://github.com/w568w"), - Contributor(R.drawable.skyleaworld, "skyleaworlder", "Android 主要开发者", "https://github.com/skyleaworlder"), - Contributor(R.drawable.fsy2001, "fsy2001", "iOS 主要开发者", "https://github.com/fsy2001"), - Contributor(R.drawable.kavinzhao, "singularity", "iOS 主要开发者", "https://github.com/singularity-s0"), - Contributor(R.drawable.ivanfei, "Ivan Fei", "App 图标 & UI 设计", "https://github.com/ivanfei-1"), - Category(getString(R.string.license)), - License("Koin", "InsertKoinIO", License.APACHE_2, "https://github.com/InsertKoinIO/koin"), - License("okhttp", "Square, Inc.", License.APACHE_2, "https://github.com/square/okhttp"), - License("retrofit", "Square, Inc.", License.APACHE_2, "https://github.com/square/retrofit"), - License("kotlinx.serialization", "Kotlin", License.APACHE_2, "https://github.com/Kotlin/kotlinx.serialization"), - License("retrofit2-kotlinx-serialization-converter", "Jake Wharton", License.APACHE_2, "https://github.com/JakeWharton/retrofit2-kotlinx-serialization-converter"), - License("kotlinx-datetime", "Kotlin", License.APACHE_2, "https://github.com/Kotlin/kotlinx-datetime"), - License("jsoup", "jhy", License.MIT, "https://github.com/jhy/jsoup"), - License("Android Jetpack", "google", License.MIT, "https://maven.google.com/"), - License("about-page", "drakeet", License.APACHE_2, "https://github.com/PureWriter/about-page"), - License("MultiType", "drakeet", License.APACHE_2, "https://github.com/drakeet/MultiType"), - License("junit4", "junit-team", "Eclipse Public License 1.0", "https://github.com/junit-team/junit4"), - License("shared-preferences-mock", "Ivan Shafran", License.MIT, "https://github.com/IvanShafran/shared-preferences-mock"), - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/BrowserActivity.kt b/app/src/main/java/com/fduhole/danxinative/BrowserActivity.kt deleted file mode 100644 index 5b2c611..0000000 --- a/app/src/main/java/com/fduhole/danxinative/BrowserActivity.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.fduhole.danxinative - -import android.Manifest -import android.os.Bundle -import android.view.LayoutInflater -import android.webkit.GeolocationPermissions -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowCompat -import androidx.lifecycle.lifecycleScope -import com.fduhole.danxinative.databinding.ActivityBrowserBinding -import com.permissionx.guolindev.PermissionX -import kotlinx.coroutines.delay - -class BrowserActivity : AppCompatActivity() { - companion object { - const val KEY_URL = "url" - const val KEY_JAVASCRIPT = "js" - const val KEY_EXECUTE_IF_START_WITH = "executeIfStartWith" - } - - private val binding: ActivityBrowserBinding by lazy { ActivityBrowserBinding.inflate(LayoutInflater.from(this)) } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) - setContentView(binding.root) - val url = intent.getStringExtra(KEY_URL) - val javascript = intent.getStringExtra(KEY_JAVASCRIPT) - val feature = intent.getStringExtra(KEY_EXECUTE_IF_START_WITH) - if (url.isNullOrBlank()) { - finish() - return - } - - binding.actBrowser.settings.javaScriptEnabled = true - binding.actBrowser.settings.domStorageEnabled = true - binding.actBrowser.settings.builtInZoomControls = true - binding.actBrowser.webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - if (url != null && feature != null && url.startsWith(feature)) { - lifecycleScope.launchWhenStarted { - delay(1000) - binding.actBrowser.evaluateJavascript(javascript.orEmpty()) {} - } - } - } - } - binding.actBrowser.webChromeClient = object : WebChromeClient() { - override fun onGeolocationPermissionsShowPrompt(origin: String?, callback: GeolocationPermissions.Callback?) { - super.onGeolocationPermissionsShowPrompt(origin, callback) - PermissionX.init(this@BrowserActivity) - .permissions(Manifest.permission.ACCESS_COARSE_LOCATION) - .explainReasonBeforeRequest() - .onExplainRequestReason { scope, deniedList -> - scope.showRequestReasonDialog(deniedList, "我们向您请求大致位置权限,是因为当前页面正在调用 Geolocation API 来获知您的大致位置。", "允许", "拒绝") - } - .request { allGranted, grantedList, deniedList -> - callback?.invoke(origin, allGranted, allGranted) - } - } - } - binding.actBrowser.loadUrl(url) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/DanXiApplication.kt b/app/src/main/java/com/fduhole/danxinative/DanXiApplication.kt deleted file mode 100644 index 403248a..0000000 --- a/app/src/main/java/com/fduhole/danxinative/DanXiApplication.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.fduhole.danxinative - -import android.app.Application -import com.fduhole.danxinative.state.appModule -import org.koin.android.ext.koin.androidContext -import org.koin.android.ext.koin.androidLogger -import org.koin.core.context.startKoin - -class DanXiApplication : Application() { - override fun onCreate() { - super.onCreate() - startKoin { - androidLogger() - androidContext(this@DanXiApplication) - modules(appModule) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/LoginActivity.kt b/app/src/main/java/com/fduhole/danxinative/LoginActivity.kt deleted file mode 100644 index 8a8e6e7..0000000 --- a/app/src/main/java/com/fduhole/danxinative/LoginActivity.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.fduhole.danxinative - -import android.os.Bundle -import android.view.LayoutInflater -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowCompat -import com.fduhole.danxinative.databinding.ActivityLoginBinding - -class LoginActivity : AppCompatActivity() { - private val binding: ActivityLoginBinding by lazy { ActivityLoginBinding.inflate(LayoutInflater.from(this)) } - override fun onCreate(savedInstanceState: Bundle?) { - WindowCompat.setDecorFitsSystemWindows(window, false) - super.onCreate(savedInstanceState) - setContentView(binding.root) - setSupportActionBar(binding.actLoginToolbar) - } - - /* - We do not permit user to go back to the last page. - We will exit the app if user refuses to log in. - */ - override fun onBackPressed() = finishAffinity() -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/MainActivity.kt b/app/src/main/java/com/fduhole/danxinative/MainActivity.kt deleted file mode 100644 index b915c80..0000000 --- a/app/src/main/java/com/fduhole/danxinative/MainActivity.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.fduhole.danxinative - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowCompat -import androidx.navigation.NavController -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.setupWithNavController -import com.fduhole.danxinative.databinding.ActivityMainBinding -import com.fduhole.danxinative.state.GlobalState -import org.koin.android.ext.android.inject - -class MainActivity : AppCompatActivity() { - private val globalState: GlobalState by inject() - private lateinit var appBarConfiguration: AppBarConfiguration - private val binding: ActivityMainBinding by lazy { ActivityMainBinding.inflate(LayoutInflater.from(this)) } - private val navController: NavController by lazy { - val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_content_main) as NavHostFragment - fragment.navController - } - - override fun onCreate(savedInstanceState: Bundle?) { - WindowCompat.setDecorFitsSystemWindows(window, false) - super.onCreate(savedInstanceState) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) - binding.actMainBottomNavigation.setupWithNavController(navController) - - jumpIfNotLogin() - } - - private fun jumpIfNotLogin() { - if (globalState.person == null) { - startActivity(Intent(this, LoginActivity::class.java)) - } - } - - override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp(appBarConfiguration) - || super.onSupportNavigateUp() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/SingleFragmentActivity.kt b/app/src/main/java/com/fduhole/danxinative/SingleFragmentActivity.kt deleted file mode 100644 index df96829..0000000 --- a/app/src/main/java/com/fduhole/danxinative/SingleFragmentActivity.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.fduhole.danxinative - -import android.app.Application -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import com.fduhole.danxinative.databinding.ActivitySingleFragmentBinding - -class SingleFragmentActivity : AppCompatActivity() { - private val binding: ActivitySingleFragmentBinding by lazy { ActivitySingleFragmentBinding.inflate(LayoutInflater.from(this)) } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setSupportActionBar(binding.actSingleFragmentToolbar) - - supportFragmentManager.beginTransaction() - .replace(R.id.act_single_fragment_fragment, intent.getSerializableExtra(KEY_FRAGMENT_CLASS)!! as Class, intent.getBundleExtra(KEY_FRAGMENT_ARGUMENTS)) - .commitNow() - } - - companion object { - const val KEY_FRAGMENT_CLASS = "class" - const val KEY_FRAGMENT_ARGUMENTS = "arguments" - fun showFragment(context: Context, clazz: Class) { - showFragment(context, clazz, null) - } - - fun showFragment(context: Context, clazz: Class, arguments: Bundle?) { - val intent = Intent(context, SingleFragmentActivity::class.java) - .putExtra(KEY_FRAGMENT_CLASS, clazz) - - arguments?.let { intent.putExtra(KEY_FRAGMENT_ARGUMENTS, it) } - - if (context is Application) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/base/Feature.kt b/app/src/main/java/com/fduhole/danxinative/base/Feature.kt deleted file mode 100644 index c543208..0000000 --- a/app/src/main/java/com/fduhole/danxinative/base/Feature.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.fduhole.danxinative.base - -import kotlinx.coroutines.CoroutineScope - - -abstract class Feature { - lateinit var callback: () -> Unit - lateinit var featureScope: CoroutineScope - abstract fun getClickable(): Boolean - open fun onClick() {} - open fun inProgress(): Boolean = false - open fun getIconId(): Int? = null - abstract fun getTitle(): String - abstract fun getSubTitle(): String - fun initFeature(callback: () -> Unit, featureScope: CoroutineScope) { - this.callback = callback - this.featureScope = featureScope - } - - open fun onCreated() {} - fun notifyChanged() = callback.invoke() -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanAAONoticesFeature.kt b/app/src/main/java/com/fduhole/danxinative/base/feature/FudanAAONoticesFeature.kt deleted file mode 100644 index 3b33d14..0000000 --- a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanAAONoticesFeature.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.fduhole.danxinative.base.feature - -import android.content.Context -import com.fduhole.danxinative.R -import com.fduhole.danxinative.SingleFragmentActivity -import com.fduhole.danxinative.base.Feature -import com.fduhole.danxinative.ui.fdu.AAONoticesFragment -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class FudanAAONoticesFeature : Feature(), KoinComponent { - private val applicationContext: Context by inject() - override fun getClickable(): Boolean = true - - override fun getTitle(): String = "教务处通知" - override fun getIconId(): Int = R.drawable.ic_baseline_newspaper_24 - - override fun getSubTitle(): String = "轻触以查看" - override fun onClick() = SingleFragmentActivity.showFragment(applicationContext, AAONoticesFragment::class.java) -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanDailyFeature.kt b/app/src/main/java/com/fduhole/danxinative/base/feature/FudanDailyFeature.kt deleted file mode 100644 index ccb40fc..0000000 --- a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanDailyFeature.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.fduhole.danxinative.base.feature - -import android.content.Context -import android.content.Intent -import com.fduhole.danxinative.BrowserActivity -import com.fduhole.danxinative.R -import com.fduhole.danxinative.base.Feature -import com.fduhole.danxinative.repository.fdu.ZLAppRepository -import com.fduhole.danxinative.state.GlobalState -import com.fduhole.danxinative.util.FDULoginUtils.Companion.uisLoginJavaScript -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -enum class FudanDailyStatus { - NONE, ERROR, LOADING, NOT_TICKED, TICKED -} - -class FudanDailyFeature : Feature(), KoinComponent { - private val zlAppRepository: ZLAppRepository by inject() - private val globalState: GlobalState by inject() - private var status: FudanDailyStatus = FudanDailyStatus.NONE - private var loadingJob: Job? = null - private val applicationContext: Context by inject() - - override fun getTitle(): String = "平安复旦" - - override fun getSubTitle(): String = when (status) { - FudanDailyStatus.NONE -> "轻触以查看" - FudanDailyStatus.ERROR -> "发生错误,轻触重试" - FudanDailyStatus.LOADING -> "加载中" - FudanDailyStatus.NOT_TICKED -> "今日尚未打卡,轻触进行" - FudanDailyStatus.TICKED -> "今日已打卡" - } - - override fun getIconId(): Int = R.drawable.ic_baseline_library_add_check_24 - - override fun inProgress(): Boolean = status == FudanDailyStatus.LOADING - - override fun getClickable(): Boolean = true - - override fun onClick() { - loadingJob?.cancel() - when (status) { - FudanDailyStatus.NOT_TICKED -> { - val intent = Intent(applicationContext, BrowserActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(BrowserActivity.KEY_URL, "https://zlapp.fudan.edu.cn/site/ncov/fudanDaily") - .putExtra(BrowserActivity.KEY_JAVASCRIPT, uisLoginJavaScript(globalState.person!!)) - .putExtra(BrowserActivity.KEY_EXECUTE_IF_START_WITH, "https://uis.fudan.edu.cn/authserver/login") - applicationContext.startActivity(intent) - } - FudanDailyStatus.LOADING -> {} - FudanDailyStatus.TICKED -> {} - else -> { - status = FudanDailyStatus.LOADING - notifyChanged() - - loadingJob = featureScope.launch { - status = try { - if (zlAppRepository.hasTick()) { - FudanDailyStatus.TICKED - } else { - FudanDailyStatus.NOT_TICKED - } - } catch (e: Throwable) { - FudanDailyStatus.ERROR - } - notifyChanged() - } - } - } - - } - - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanECardFeature.kt b/app/src/main/java/com/fduhole/danxinative/base/feature/FudanECardFeature.kt deleted file mode 100644 index 16c38cf..0000000 --- a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanECardFeature.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.fduhole.danxinative.base.feature - -import android.content.Context -import com.fduhole.danxinative.R -import com.fduhole.danxinative.SingleFragmentActivity -import com.fduhole.danxinative.base.Feature -import com.fduhole.danxinative.repository.fdu.ECardRepository -import com.fduhole.danxinative.ui.fdu.ECardFragment -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -enum class FudanECardStatus { - IDLE, LOADING, LOADED, ERROR -} - -class FudanECardFeature : Feature(), KoinComponent { - private val applicationContext: Context by inject() - private val repo: ECardRepository by inject() - - private var status = FudanECardStatus.IDLE - private var loadingJob: Job? = null - private var eCardDescription: String = "" - override fun inProgress(): Boolean = status == FudanECardStatus.LOADING - override fun getClickable(): Boolean = true - override fun getTitle(): String = "校园卡余额" - override fun getIconId(): Int = R.drawable.ic_baseline_payment_24 - override fun getSubTitle(): String = when (status) { - FudanECardStatus.IDLE -> "轻触以查看" - FudanECardStatus.LOADING -> "加载中" - FudanECardStatus.LOADED -> eCardDescription - FudanECardStatus.ERROR -> "发生错误,轻触重试" - } - - override fun onClick() { - loadingJob?.cancel() - when (status) { - FudanECardStatus.LOADING -> {} - FudanECardStatus.LOADED -> SingleFragmentActivity.showFragment(applicationContext, ECardFragment::class.java) - else -> { - status = FudanECardStatus.LOADING - notifyChanged() - loadingJob = featureScope.launch { - status = try { - val cardInfo = repo.getCardPersonInfo() - eCardDescription = cardInfo.balance - FudanECardStatus.LOADED - } catch (e: Throwable) { - e.printStackTrace() - FudanECardStatus.ERROR - } - notifyChanged() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanLibraryAttendanceFeature.kt b/app/src/main/java/com/fduhole/danxinative/base/feature/FudanLibraryAttendanceFeature.kt deleted file mode 100644 index 1a4bbec..0000000 --- a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanLibraryAttendanceFeature.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.fduhole.danxinative.base.feature - -import com.fduhole.danxinative.R -import com.fduhole.danxinative.base.Feature -import com.fduhole.danxinative.repository.fdu.LibraryRepository -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -enum class FudanLibraryAttendanceStatus { - IDLE, LOADING, LOADED, ERROR -} - -class FudanLibraryAttendanceFeature : Feature(), KoinComponent { - companion object { - val LIBRARY_NAME = arrayOf( - "文科馆", "理科馆", "医科馆1-6层", - "张江馆", "江湾馆", "医科馆B1" - ) - } - - private val repo: LibraryRepository by inject() - - private var status = FudanLibraryAttendanceStatus.IDLE - private var loadingJob: Job? = null - private var attendanceContent: String = "" - override fun inProgress(): Boolean = status == FudanLibraryAttendanceStatus.LOADING - override fun getClickable(): Boolean = true - override fun getTitle(): String = "图书馆人数" - override fun getIconId(): Int = R.drawable.ic_baseline_person_24 - override fun getSubTitle(): String = when (status) { - FudanLibraryAttendanceStatus.IDLE -> "轻触以查看" - FudanLibraryAttendanceStatus.LOADING -> "加载中" - FudanLibraryAttendanceStatus.LOADED -> attendanceContent - FudanLibraryAttendanceStatus.ERROR -> "发生错误,轻触重试" - } - - override fun onClick() { - loadingJob?.cancel() - status = FudanLibraryAttendanceStatus.LOADING - notifyChanged() - loadingJob = featureScope.launch { - status = try { - val attendanceList = repo.getAttendanceList() - attendanceContent = LIBRARY_NAME.zip(attendanceList) - .map { "${it.first}: ${it.second} " } - .reduce { acc, s -> acc + s } - FudanLibraryAttendanceStatus.LOADED - } catch (e: Throwable) { - FudanLibraryAttendanceStatus.ERROR - } - notifyChanged() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanQRCodeFeature.kt b/app/src/main/java/com/fduhole/danxinative/base/feature/FudanQRCodeFeature.kt deleted file mode 100644 index c2e3b54..0000000 --- a/app/src/main/java/com/fduhole/danxinative/base/feature/FudanQRCodeFeature.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.fduhole.danxinative.base.feature - -import android.content.Context -import android.os.Bundle -import com.fduhole.danxinative.R -import com.fduhole.danxinative.SingleFragmentActivity -import com.fduhole.danxinative.base.Feature -import com.fduhole.danxinative.repository.fdu.ECardRepository -import com.fduhole.danxinative.ui.fdu.QRCodeFragment -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -enum class FudanQRCodeStatus { - IDLE, LOADING, ERROR -} - -class FudanQRCodeFeature : Feature(), KoinComponent { - private val appContext: Context by inject() - private val repo: ECardRepository by inject() - - private var status = FudanQRCodeStatus.IDLE - private var loadingJob: Job? = null - private var errorDescription: String? = "" - override fun inProgress(): Boolean = status == FudanQRCodeStatus.LOADING - override fun getClickable(): Boolean = true - override fun getTitle(): String = "复旦生活码" - override fun getIconId(): Int = R.drawable.ic_baseline_qr_code_24 - override fun getSubTitle(): String = when (status) { - FudanQRCodeStatus.IDLE -> "轻触以显示" - FudanQRCodeStatus.LOADING -> "加载中" - FudanQRCodeStatus.ERROR -> "发生错误,轻触重试:${errorDescription}" - } - - override fun onClick() { - loadingJob?.cancel() - status = FudanQRCodeStatus.LOADING - notifyChanged() - loadingJob = featureScope.launch { - status = try { - SingleFragmentActivity.showFragment(appContext, QRCodeFragment::class.java, Bundle().apply { - putString(QRCodeFragment.ARG_QR_CODE, repo.getQRCode()) - }) - FudanQRCodeStatus.IDLE - } catch (e: Throwable) { - errorDescription = e.localizedMessage - FudanQRCodeStatus.ERROR - } - notifyChanged() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/model/AAONotice.kt b/app/src/main/java/com/fduhole/danxinative/model/AAONotice.kt deleted file mode 100644 index 47a7541..0000000 --- a/app/src/main/java/com/fduhole/danxinative/model/AAONotice.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.fduhole.danxinative.model - -import kotlinx.serialization.Serializable - -@Serializable -data class AAONotice(val title: String, val url: String, val time: String) diff --git a/app/src/main/java/com/fduhole/danxinative/model/CardPersonInfo.kt b/app/src/main/java/com/fduhole/danxinative/model/CardPersonInfo.kt deleted file mode 100644 index ead8166..0000000 --- a/app/src/main/java/com/fduhole/danxinative/model/CardPersonInfo.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.fduhole.danxinative.model - -import kotlinx.serialization.Serializable - -@Serializable -data class CardPersonInfo(val balance: String, val name: String) - diff --git a/app/src/main/java/com/fduhole/danxinative/model/CardRecord.kt b/app/src/main/java/com/fduhole/danxinative/model/CardRecord.kt deleted file mode 100644 index a5da5c3..0000000 --- a/app/src/main/java/com/fduhole/danxinative/model/CardRecord.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.fduhole.danxinative.model - -import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable - -@Serializable -data class CardRecord(val time: Instant, val type: String, val location: String, val payment: String) - diff --git a/app/src/main/java/com/fduhole/danxinative/model/PersonInfo.kt b/app/src/main/java/com/fduhole/danxinative/model/PersonInfo.kt deleted file mode 100644 index 201113f..0000000 --- a/app/src/main/java/com/fduhole/danxinative/model/PersonInfo.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.fduhole.danxinative.model - -import kotlinx.serialization.Serializable - -@Serializable -data class PersonInfo(val name: String, val id: String, val password: String){ - override fun toString(): String = String.format("%s (%s)", name, id) -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/BaseRepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/BaseRepository.kt deleted file mode 100644 index 0880fae..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/BaseRepository.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.fduhole.danxinative.repository - -import com.fduhole.danxinative.util.net.MemoryCookieJar -import okhttp3.JavaNetCookieJar -import okhttp3.OkHttpClient -import java.net.CookieManager -import java.net.CookiePolicy - - -abstract class BaseRepository { - companion object { - val clients: MutableMap = mutableMapOf() - val cookieJars: MutableMap = mutableMapOf() - } - - val cookieJar: MemoryCookieJar - get() { - if (!cookieJars.containsKey(getScopeId())) { - cookieJars[getScopeId()] = MemoryCookieJar(JavaNetCookieJar(CookieManager(null, CookiePolicy.ACCEPT_ALL))) - } - return cookieJars[getScopeId()]!! - } - - var client: OkHttpClient - get() { - if (!clients.containsKey(getScopeId())) { - client = clientFactory().build() - } - return clients[getScopeId()]!! - } - set(value) { - clients[getScopeId()] = value - } - - open fun clientFactory(): OkHttpClient.Builder = clientFactoryNoCookie().cookieJar(cookieJar) - - fun clientFactoryNoCookie(): OkHttpClient.Builder = OkHttpClient.Builder().cache(null) - - /** - * Get the scope id of the repository. - * - * The repositories with the same id will share one client and cookie jar. - */ - abstract fun getScopeId(): String -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/fdu/AAORepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/fdu/AAORepository.kt deleted file mode 100644 index 5b955e3..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/fdu/AAORepository.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.fduhole.danxinative.repository.fdu - -import com.fduhole.danxinative.model.AAONotice -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import okhttp3.Request -import okhttp3.Response -import org.jsoup.Jsoup -import org.jsoup.select.Elements -import kotlin.coroutines.resume - -class AAORepository : BaseFDURepository() { - companion object { - const val TYPE_NOTICE_ANNOUNCEMENT = "9397" - private const val HOST = "https://jwc.fudan.edu.cn" - } - - override fun getUISLoginURL(): String = - "https://uis.fudan.edu.cn/authserver/login?service=http%3A%2F%2Fjwc.fudan.edu.cn%2Feb%2Fb7%2Fc9397a388023%2Fpage.psp"; - - override fun getScopeId(): String = "fudan.edu.cn" - - fun getNoticeListUrl(type: String, page: Int): String = - "$HOST/$type/list${if (page <= 1) "" else page}.htm" - - suspend fun getNoticeList(page: Int, type: String = TYPE_NOTICE_ANNOUNCEMENT): List = - withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - val res: Response = client.newCall( - Request.Builder() - .url(getNoticeListUrl(type, page)).get() - .build() - ).execute() - val doc = Jsoup.parse(res.body!!.string()) - val noticeList = ArrayList() - for (element in doc.select(".wp_article_list_table > tbody > tr > td > table > tbody")) { - val noticeInfo: Elements = element.select("tr").select("td") - val notice = AAONotice( - noticeInfo[0].text().trim(), - HOST + noticeInfo[0].select("a").attr("href"), - noticeInfo[1].text().trim() - ) - noticeList.add(notice) - } - it.resume(noticeList) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/fdu/BaseFDURepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/fdu/BaseFDURepository.kt deleted file mode 100644 index 815f7a3..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/fdu/BaseFDURepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.fduhole.danxinative.repository.fdu - -import com.fduhole.danxinative.repository.BaseRepository -import okhttp3.OkHttpClient - -abstract class BaseFDURepository : BaseRepository() { - override fun clientFactory(): OkHttpClient.Builder = super.clientFactory() - .addInterceptor(UISAuthInterceptor(this)) - - abstract fun getUISLoginURL(): String -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/fdu/ECardRepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/fdu/ECardRepository.kt deleted file mode 100644 index 6dd315c..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/fdu/ECardRepository.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.fduhole.danxinative.repository.fdu - -import com.fduhole.danxinative.model.CardPersonInfo -import com.fduhole.danxinative.model.CardRecord -import com.fduhole.danxinative.util.addMap -import com.fduhole.danxinative.util.between -import com.fduhole.danxinative.util.toDateTimeString -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import kotlinx.datetime.Clock -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant -import okhttp3.FormBody -import okhttp3.Headers.Companion.toHeaders -import okhttp3.Request -import org.jsoup.Jsoup -import kotlin.coroutines.resume -import kotlin.time.Duration.Companion.days - -class ECardRepository : BaseFDURepository() { - override fun getUISLoginURL(): String = "https://uis.fudan.edu.cn/authserver/login?service=https%3A%2F%2Fecard.fudan.edu.cn%2Fepay%2Fj_spring_cas_security_check" - - override fun getScopeId(): String = "fudan.edu.cn" - - companion object { - const val USER_DETAIL_URL = "https://ecard.fudan.edu.cn/epay/myepay/index" - const val CONSUME_DETAIL_URL = "https://ecard.fudan.edu.cn/epay/consume/query" - const val CONSUME_DETAIL_CSRF_URL = "https://ecard.fudan.edu.cn/epay/consume/index" - val CONSUME_DETAIL_HEADER = mapOf( - "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0", - "Accept" to "text/xml", - "Accept-Language" to "zh-CN,en-US;q=0.7,en;q=0.3", - "Content-Type" to "application/x-www-form-urlencoded", - "Origin" to "https://ecard.fudan.edu.cn", - "DNT" to "1", - "Connection" to "keep-alive", - "Referer" to "https://ecard.fudan.edu.cn/epay/consume/index", - "Sec-GPC" to "1" - ) - const val QR_URL = "https://ecard.fudan.edu.cn/epay/wxpage/fudan/zfm/qrcode" - } - - /** - * Get personal info (and card info). - */ - suspend fun getCardPersonInfo(): CardPersonInfo = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - val res = client.newCall(Request.Builder().get().url(USER_DETAIL_URL).build()).execute() - val body = res.body?.string() - val balance = requireNotNull(body?.between("

账户余额:", "元

")) { "balance cannot be null." } - val name = requireNotNull(body?.between("姓名:", "

")) { "name cannot be null." } - it.resume(CardPersonInfo(balance, name)) - } - } - - /** - * Get the card records of last [dayNum] days. - * - * If [dayNum] = 0, it returns the last ten records; - * if [dayNum] < 0, it throws an error. - */ - suspend fun getCardRecords(dayNum: Int): List { - val payloadAndPageNum = getPagedCardRecordsPayloadAndPageNum(dayNum) - val list = arrayListOf() - for (i in 1..payloadAndPageNum.second) { - list.addAll(getPagedCardRecords(payloadAndPageNum.first, i)) - } - return list - } - - suspend fun getPagedCardRecords(payload: Map, pageIndex: Int): List = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - it.resume(requireNotNull(loadOnePageCardRecord(payload, pageIndex)) { "Got null data at page index $pageIndex" }) - } - } - - suspend fun getPagedCardRecordsPayloadAndPageNum(dayNum: Int): Pair, Int> = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - require(dayNum >= 0) { "Day number should not be less than 0." } - val consumeCsrfPageResponse = client.newCall(Request.Builder().get().url(CONSUME_DETAIL_CSRF_URL).build()).execute() - val consumeCsrfPageSoup = consumeCsrfPageResponse.body?.string()?.let { it1 -> Jsoup.parse(it1) } - val metas = consumeCsrfPageSoup?.getElementsByTag("meta") - val element = metas?.find { e -> e.attr("name") == "_csrf" } - val csrfId = requireNotNull(element?.attr("content")) { "CSRF id is null." } - - // Build the request body. - val end = Clock.System.now() - val backDays = if (dayNum == 0) 180 else dayNum - val start = end.minus(backDays.days) - val datePattern = "yyyy-MM-dd" - val payload = mapOf( - "aaxmlrequest" to "true", - "pageNo" to "1", - "tabNo" to "1", - "pager.offset" to "10", - "tradename" to "", - "starttime" to start.toDateTimeString(datePattern), - "endtime" to end.toDateTimeString(datePattern), - "timetype" to "1", - "_tradedirect" to "on", - "_csrf" to csrfId, - ) - // Get the number of pages, only when logDays > 0. - var totalPages = 1 - if (dayNum > 0) { - val detailResponse = client.newCall(Request.Builder() - .post(FormBody.Builder().addMap(payload).build()) - .url(CONSUME_DETAIL_URL) - .headers(CONSUME_DETAIL_HEADER.toHeaders()) - .build()).execute() - totalPages = detailResponse.body?.string()?.between("/", "页")?.toIntOrNull() ?: 0 - } - it.resume(payload to totalPages) - } - } - - suspend fun getQRCode(): String = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - val response = client.newCall(Request.Builder() - .get() - .url(QR_URL) - .build()).execute() - val soup = Jsoup.parse(requireNotNull(response.body?.string()) { "Get null response from server." }) - val result = soup.selectFirst("#myText")?.attr("value") - it.resume(requireNotNull(result) { "Cannot find `value` in the response page." }) - } - } - - private fun loadOnePageCardRecord(payload: Map, pageIndex: Int): List? { - val requestData = payload.toMutableMap() - requestData["pageNo"] = pageIndex.toString() - val detailResponse = client.newCall(Request.Builder() - .post(FormBody.Builder().addMap(requestData).build()) - .url(CONSUME_DETAIL_URL) - .headers(CONSUME_DETAIL_HEADER.toHeaders()) - .build()).execute() - val soup = detailResponse.body?.string()?.between("")?.let { Jsoup.parse(it) } - val elements = soup?.selectFirst("tbody")?.getElementsByTag("tr") - return elements?.map { - val details = requireNotNull(it.getElementsByTag("td")) { "td element cannot be null." } - val dateStr = details[0].child(0).text().replace(".", "-") - val timeStr = details[0].child(1).text().trim().chunked(2).joinToString(":") - CardRecord( - LocalDateTime.parse("${dateStr}T${timeStr}").toInstant(TimeZone.currentSystemDefault()), - details[1].child(0).text().trim(), - details[2].text().trim().replace(" ", ""), - details[3].text().trim().replace(" ", ""), - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/fdu/EhallRepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/fdu/EhallRepository.kt deleted file mode 100644 index 7fffc34..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/fdu/EhallRepository.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.fduhole.danxinative.repository.fdu - -import com.fduhole.danxinative.model.PersonInfo -import com.fduhole.danxinative.repository.BaseRepository -import com.fduhole.danxinative.util.net.MemoryCookieJar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import okhttp3.JavaNetCookieJar -import okhttp3.Request -import org.json.JSONObject -import java.net.CookieManager -import java.net.CookiePolicy -import kotlin.coroutines.resume - -data class StudentInfo(val name: String?, val userTypeName: String?, val userDepartment: String?) -class EhallRepository : BaseRepository() { - companion object { - const val INFO_URL = - "https://ehall.fudan.edu.cn/jsonp/ywtb/info/getUserInfoAndSchoolInfo.json" - const val LOGIN_URL = - "https://uis.fudan.edu.cn/authserver/login?service=http%3A%2F%2Fehall.fudan.edu.cn%2Flogin%3Fservice%3Dhttp%3A%2F%2Fehall.fudan.edu.cn%2Fywtb-portal%2Ffudan%2Findex.html" - } - - - override fun getScopeId(): String = "ehall.fudan.edu.cn" - - suspend fun getStudentInfo(info: PersonInfo): StudentInfo = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - // Execute manual logging in. - val tmpMemoryCookieJar = MemoryCookieJar(JavaNetCookieJar(CookieManager(null, CookiePolicy.ACCEPT_ALL))) - val tmpClient = clientFactoryNoCookie().cookieJar(tmpMemoryCookieJar).build() - // Login and absorb the auth cookie. - tmpMemoryCookieJar.replaceBy(UISAuthInterceptor.login(info.id, info.password, LOGIN_URL)) - - val response = tmpClient.newCall(Request.Builder().url(INFO_URL).get().build()).execute() - val obj = JSONObject(response.body?.string() ?: "").getJSONObject("data") - it.resume(StudentInfo(obj.optString("userName"), obj.optString("userTypeName"), obj.optString("userDepartment"))) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/fdu/LibraryRepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/fdu/LibraryRepository.kt deleted file mode 100644 index 9fff9da..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/fdu/LibraryRepository.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.fduhole.danxinative.repository.fdu - -import com.fduhole.danxinative.repository.BaseRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import okhttp3.Request -import okhttp3.Response -import kotlin.coroutines.resume - -class LibraryRepository : BaseRepository() { - companion object { - const val GET_INFO_URL = "http://10.55.101.62/book/show" - } - - override fun getScopeId(): String = "10.55.101.62" - - /** - * @return a list whose size is 6. - * The sequence is 文科馆、理科馆、医科馆1-6层、张江馆、江湾馆、医科馆B1 - */ - suspend fun getAttendanceList(): List = - withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - val res: Response = client.newCall( - Request.Builder() - .url(GET_INFO_URL).get() - .build() - ).execute() - val regex = "(?<=当前在馆人数:)[0-9]+".toRegex() - val attendanceList = regex.findAll(res.body!!.string()) - .map { elem -> elem.value.toIntOrNull()?: 0 } - .toList() - it.resume(attendanceList) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/fdu/UISAuthInterceptor.kt b/app/src/main/java/com/fduhole/danxinative/repository/fdu/UISAuthInterceptor.kt deleted file mode 100644 index 0f9a7d2..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/fdu/UISAuthInterceptor.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.fduhole.danxinative.repository.fdu - -import android.content.res.Resources -import com.fduhole.danxinative.R -import com.fduhole.danxinative.model.PersonInfo -import com.fduhole.danxinative.state.GlobalState -import com.fduhole.danxinative.util.ExplainableException -import com.fduhole.danxinative.util.net.RetryCount -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import okhttp3.* -import org.jsoup.Jsoup -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.net.CookieManager -import java.net.CookiePolicy - -class UISLoginException(val type: UISLoginExceptionType) : ExplainableException() { - override fun explain(context: Resources): String = when (type) { - UISLoginExceptionType.WeakPassword -> context.getString(R.string.weak_password_note) - UISLoginExceptionType.UnderMaintenance -> context.getString(R.string.under_maintenance_note) - UISLoginExceptionType.Unknown -> context.getString(R.string.uis_login_unknown_note) - } -} - -class UISLoginUnrecoverableException(val type: UISLoginUnrecoverableExceptionType) : ExplainableException() { - override fun explain(context: Resources): String = when (type) { - UISLoginUnrecoverableExceptionType.NeedCaptcha -> context.getString(R.string.need_captcha_note) - UISLoginUnrecoverableExceptionType.InvalidCredentials -> context.getString(R.string.invalid_credentials_note) - } -} - -enum class UISLoginExceptionType { - WeakPassword, UnderMaintenance, Unknown -} - -enum class UISLoginUnrecoverableExceptionType { - NeedCaptcha, InvalidCredentials -} - -class UISAuthInterceptor(private val repository: BaseFDURepository, private val tempPersonInfo: PersonInfo? = null, private val maxRetryTimes: Int = 2) : Interceptor, - KoinComponent { - private val globalState: GlobalState by inject() - private val personInfo: PersonInfo? by lazy { tempPersonInfo ?: globalState.person } - - companion object { - private val mutex = Mutex() - const val UIS_HOST = "uis.fudan.edu.cn" - const val CAPTCHA_CODE_NEEDED = "请输入验证码"; - const val CREDENTIALS_INVALID = "密码有误"; - const val WEAK_PASSWORD = "弱密码提示"; - const val UNDER_MAINTENANCE = "网络维护中 | Under Maintenance"; - fun login(id: String, password: String, loginUrl: String): JavaNetCookieJar { - val tmpCookieJar = JavaNetCookieJar(CookieManager(null, CookiePolicy.ACCEPT_ALL)) - val tmpClient = OkHttpClient.Builder().cookieJar(tmpCookieJar).cache(null).build() - val res = tmpClient.newCall(Request.Builder().url(loginUrl).get().build()).execute() - val doc = Jsoup.parse(res.body!!.string()) - val payload = FormBody.Builder() - for (element in doc.select("input")) { - if (element.attr("type") != "button" && element.attr("name") != "username" && element.attr("name") != "password") { - payload.add(element.attr("name"), element.attr("value")) - } - } - payload.add("username", id) - payload.add("password", password) - - val response = tmpClient.newCall(Request.Builder().url(loginUrl).post(payload.build()).build()).execute() - val responseBody = response.body?.string().orEmpty() - if (responseBody.contains(CAPTCHA_CODE_NEEDED)) { - throw UISLoginUnrecoverableException(UISLoginUnrecoverableExceptionType.NeedCaptcha) - } else if (responseBody.contains(CREDENTIALS_INVALID)) { - throw UISLoginUnrecoverableException(UISLoginUnrecoverableExceptionType.InvalidCredentials) - } else if (responseBody.contains(UNDER_MAINTENANCE)) { - throw UISLoginException(UISLoginExceptionType.UnderMaintenance) - } else if (responseBody.contains(WEAK_PASSWORD)) { - throw UISLoginException(UISLoginExceptionType.WeakPassword) - } - return tmpCookieJar - } - } - - override fun intercept(chain: Interceptor.Chain): Response = runBlocking { - val request = chain.request() - val retryCount = request.tag(RetryCount::class.java) - if (retryCount != null && retryCount.retryTime > maxRetryTimes) { - throw UISLoginException(UISLoginExceptionType.Unknown) - } - var response = chain.proceed(request) - if (request.url.host.contains(UIS_HOST)) return@runBlocking response - - if (response.request.url.host.contains(UIS_HOST)) { - mutex.withLock { - repository.cookieJar.replaceBy(login(personInfo?.id.orEmpty(), personInfo?.password.orEmpty(), repository.getUISLoginURL())) - response = repository.client - .newCall(request.newBuilder() - .tag(RetryCount((retryCount?.retryTime ?: 0) + 1)) - .build()) - .execute() - } - } - return@runBlocking response - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/fdu/ZLAppRepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/fdu/ZLAppRepository.kt deleted file mode 100644 index d7097e3..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/fdu/ZLAppRepository.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.fduhole.danxinative.repository.fdu - -import android.text.format.DateFormat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import okhttp3.Request -import org.json.JSONObject -import java.util.* -import kotlin.coroutines.resume - -class ZLAppRepository : BaseFDURepository() { - companion object { - const val GET_INFO_URL = "https://zlapp.fudan.edu.cn/ncov/wap/fudan/get-info" - } - - override fun getUISLoginURL(): String = - "https://uis.fudan.edu.cn/authserver/login?service=https%3A%2F%2Fzlapp.fudan.edu.cn%2Fa_fudanzlapp%2Fapi%2Fsso%2Findex%3Fredirect%3Dhttps%253A%252F%252Fzlapp.fudan.edu.cn%252Fsite%252Fncov%252FfudanDaily%253Ffrom%253Dhistory%26from%3Dwap" - - override fun getScopeId(): String = "fudan.edu.cn" - - private suspend fun getHistoryInfo(): String? = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { - val res = client.newCall(Request.Builder().url(GET_INFO_URL).get().build()).execute() - it.resume(res.body?.string()) - } - } - - suspend fun hasTick(): Boolean { - val obj = JSONObject(getHistoryInfo().orEmpty()) - val info = obj.optJSONObject("d")!!.optJSONObject("info") ?: return false - return info.optString("date") == DateFormat.format("yyyyMMdd", Date()) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleApiService.kt b/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleApiService.kt deleted file mode 100644 index b8c1723..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleApiService.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.fduhole.danxinative.repository.opentreehole - -import com.fduhole.danxinative.model.opentreehole.* -import retrofit2.http.* - - -interface FDUHoleApiService { - - @GET("divisions") - suspend fun getDivisions(): List - - @PUT("divisions/{id}") - suspend fun getDivision(@Path("id") id: Int?): OTDivision - - @GET("divisions/{division_id}/holes") - suspend fun getHoles( - @Path("division_id") divisionId: Int, @Query("offset") offsetOrId: String?, @Query("size") size: Int?, - ): List - - @GET("holes/{id}") - suspend fun getHole( - @Path("id") holdId: Int, - ): OTHole - - @GET("holes/{hole_id}/floors") - suspend fun getFloors( - @Path("hole_id") holeId: Int, @Query("offset") offset: Int?, @Query("order_by") orderBy: String?, @Query("size") size: Int?, @Query("sort") sort: String?, - ): List - - @GET("floors/{id}") - suspend fun getFloor( - @Path("id") id: Int, - ): OTFloor - - @GET("tags") - suspend fun getTags(): List - - @POST("divisions/{division_id}/holes") - suspend fun postHole( - @Path("division_id") divisionId: Int, @Body json: OTNewHole, - ): OTHole - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleAuthApiService.kt b/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleAuthApiService.kt deleted file mode 100644 index db3ab04..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleAuthApiService.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.fduhole.danxinative.repository.opentreehole - -import com.fduhole.danxinative.model.opentreehole.OTJWTToken -import com.fduhole.danxinative.model.opentreehole.OTLoginInfo -import com.fduhole.danxinative.model.opentreehole.OTRegisterInfo -import com.fduhole.danxinative.model.opentreehole.OTVerifyCode -import okhttp3.Response -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Query - - -interface FDUHoleAuthApiService { - @GET("verify/apikey") - suspend fun getRegisterStatus(@Query("apikey") apiKey: String, @Query("email") email: String, @Query("check_register") checkRegister: Int = 1): Response - - @GET("verify/apikey") - suspend fun getVerifyCode(@Query("apikey") apiKey: String, @Query("email") email: String): OTVerifyCode - - @GET("verify/email") - suspend fun requestEmailVerifyCode(@Query("email") email: String): Response - - @POST("register") - suspend fun register(@Body registerInfo: OTRegisterInfo): OTJWTToken - - @POST("login") - suspend fun login(@Body loginInfo: OTLoginInfo): OTJWTToken - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleRepository.kt b/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleRepository.kt deleted file mode 100644 index e12d5fc..0000000 --- a/app/src/main/java/com/fduhole/danxinative/repository/opentreehole/FDUHoleRepository.kt +++ /dev/null @@ -1,41 +0,0 @@ -@file:OptIn(ExperimentalSerializationApi::class) - -package com.fduhole.danxinative.repository.opentreehole - -import com.fduhole.danxinative.model.opentreehole.OTJWTToken -import com.fduhole.danxinative.model.opentreehole.OTLoginInfo -import com.fduhole.danxinative.repository.BaseRepository -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import retrofit2.Retrofit - -// Configure the JSON (de)serializer to match APIs' need better. -private val json = Json { - // Do not throw an exception when deserializing an object with unknown keys. - ignoreUnknownKeys = true - // Do not encode a field into the final json string if it is null. - explicitNulls = false -} - -class FDUHoleRepository : BaseRepository() { - companion object { - const val BASE_URL = "https://hole.hath.top/api/" - const val BASE_AUTH_URL = "https://testauth.hath.top/api/" - } - - private val retrofit: Retrofit by lazy { - Retrofit.Builder().baseUrl(BASE_AUTH_URL) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .client(client).build() - } - private val authApiService: FDUHoleAuthApiService by lazy { - retrofit.create(FDUHoleAuthApiService::class.java) - } - - - override fun getScopeId(): String = "fduhole.com" - - suspend fun login(email: String, password: String): OTJWTToken = authApiService.login(OTLoginInfo(password, email)) -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/state/GlobalState.kt b/app/src/main/java/com/fduhole/danxinative/state/GlobalState.kt deleted file mode 100644 index 80b8b5d..0000000 --- a/app/src/main/java/com/fduhole/danxinative/state/GlobalState.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.fduhole.danxinative.state - - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import androidx.security.crypto.MasterKeys -import com.fduhole.danxinative.model.PersonInfo -import com.fduhole.danxinative.model.opentreehole.OTJWTToken -import com.fduhole.danxinative.repository.fdu.* -import com.fduhole.danxinative.repository.opentreehole.FDUHoleRepository -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.koin.dsl.module - -val appModule = module { - single { MasterKey.Builder(get()).setKeyGenParameterSpec(MasterKeys.AES256_GCM_SPEC).build() } - single { - EncryptedSharedPreferences.create(get(), - "app_pref", - get(), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) - } - single { GlobalState(get()) } - single { ZLAppRepository() } - single { EhallRepository() } - single { FDUHoleRepository() } - single { AAORepository() } - single { LibraryRepository() } - single { ECardRepository() } -} - -class GlobalState constructor(val preferences: SharedPreferences) { - - companion object { - const val KEY_PERSON_INFO = "person_info" - const val KEY_FDUHOLE_TOKEN = "fduhole_token" - - /** - * The keys below are defined in `root_preferences.xml`. - */ - const val KEY_HIGH_CONTRAST_COLOR = "high_contrast_color" - } - - - inner class Pref { - var highContrastColor: Boolean - get() = preferences.getBoolean(KEY_HIGH_CONTRAST_COLOR, false) - set(value) = preferences.edit { putBoolean(KEY_HIGH_CONTRAST_COLOR, value) } - } - - var person: PersonInfo? - get() { - if (preferences.contains(KEY_PERSON_INFO)) { - try { - return Json.decodeFromString(preferences.getString(KEY_PERSON_INFO, "")!!) - } catch (_: Exception) { - } - } - return null - } - set(value) = preferences.edit().putString(KEY_PERSON_INFO, Json.encodeToString(value)).apply() - - var fduholeToken: OTJWTToken? - get() { - if (preferences.contains(KEY_FDUHOLE_TOKEN)) { - try { - return Json.decodeFromString(preferences.getString(KEY_FDUHOLE_TOKEN, "")!!) - } catch (_: Exception) { - } - - } - return null - } - set(value) = preferences.edit().putString(KEY_FDUHOLE_TOKEN, Json.encodeToString(value)).apply() -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/HomeFragment.kt b/app/src/main/java/com/fduhole/danxinative/ui/HomeFragment.kt deleted file mode 100644 index ae7aa45..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/HomeFragment.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.fduhole.danxinative.ui - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.fduhole.danxinative.base.Feature -import com.fduhole.danxinative.databinding.FragmentHomeBinding -import com.fduhole.danxinative.databinding.ItemFeatureCardBinding -import com.fduhole.danxinative.util.lifecycle.watch -import kotlinx.coroutines.launch - -data class HomeUiState( - val features: List = listOf(), -) - -class HomeFragment : Fragment() { - private val viewModel: HomeViewModel by viewModels() - private lateinit var binding: FragmentHomeBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentHomeBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - viewModel.initModel { (binding.fragHomeFeatureList.adapter as BaseAdapter).notifyDataSetChanged() } - - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.apply { - watch(this@repeatOnLifecycle, { it.features }) { - binding.fragHomeFeatureList.adapter = context?.let { cxt -> FeatureAdapter(cxt, it) } - } - } - } - } - } - - class FeatureAdapter(private val context: Context, private val features: List) : BaseAdapter() { - override fun getCount(): Int = features.size - - override fun getItem(position: Int): Any = features[position] - - override fun getItemId(position: Int): Long = position.toLong() - - override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { - // If we do not reuse old views, the animation on the old view (i.e. tap ripple effect) will be discarded at once. - val item = if (convertView != null) - ItemFeatureCardBinding.bind(convertView) - else - ItemFeatureCardBinding.inflate(LayoutInflater.from(context), parent, false) - item.itFeatureCardCardView.isEnabled = features[position].getClickable() - item.itFeatureCardProgressBar.visibility = if (features[position].inProgress()) View.VISIBLE else View.INVISIBLE - item.itFeatureCardCardView.setOnClickListener { features[position].onClick() } - item.itFeatureCardTitle.text = features[position].getTitle() - item.itFeatureCardSubtitle.text = features[position].getSubTitle() - item.itFeatureCardTertiaryTitle.visibility = View.GONE - features[position].getIconId()?.let { item.itFeatureCardIcon.setImageResource(it) } - return item.root - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/HomeViewModel.kt b/app/src/main/java/com/fduhole/danxinative/ui/HomeViewModel.kt deleted file mode 100644 index a042d91..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/HomeViewModel.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.fduhole.danxinative.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.fduhole.danxinative.base.feature.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -class HomeViewModel : ViewModel() { - private val _uiState = MutableStateFlow(HomeUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private suspend fun buildFeatures() { - _uiState.emit(HomeUiState(listOf( - FudanDailyFeature(), - FudanAAONoticesFeature(), - FudanLibraryAttendanceFeature(), - FudanECardFeature(), - FudanQRCodeFeature() - ))) - } - - private suspend fun ensureFeatureBuilt() { - if (_uiState.value.features.isEmpty()) { - buildFeatures() - } - } - - fun initModel(featureCallback: () -> Unit) { - viewModelScope.launch { - ensureFeatureBuilt() - for (feature in _uiState.value.features) { - feature.initFeature(featureCallback, viewModelScope) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/LoginFragment.kt b/app/src/main/java/com/fduhole/danxinative/ui/LoginFragment.kt deleted file mode 100644 index d012913..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/LoginFragment.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.fduhole.danxinative.ui - -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.fduhole.danxinative.R -import com.fduhole.danxinative.databinding.FragmentLoginBinding -import com.fduhole.danxinative.util.ErrorUtils -import com.fduhole.danxinative.util.lifecycle.watch -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch - -data class LoginUiState( - val idErrorId: Int? = null, - val passwordErrorId: Int? = null, - val loginError: Throwable? = null, - val loggingIn: Boolean = false, - val logged: Boolean = false -) - -class LoginFragment : Fragment() { - private val viewModel: LoginViewModel by viewModels() - private lateinit var binding: FragmentLoginBinding - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentLoginBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { - binding.fragLoginIdLayout.editText?.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable?) = viewModel.onIdChanged(s.toString()) - }) - binding.fragLoginPasswordLayout.editText?.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable?) = viewModel.onPasswordChanged(s.toString()) - }) - binding.fragLoginLoginButton.setOnClickListener { - viewModel.logIn( - binding.fragLoginIdLayout.editText?.text?.toString(), - binding.fragLoginPasswordLayout.editText?.text?.toString() - ) - } - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.apply { - val life = this@repeatOnLifecycle - watch(life, { it.idErrorId }) { binding.fragLoginIdLayout.error = it?.let { it1 -> getString(it1) } } - watch(life, { it.passwordErrorId }) { binding.fragLoginPasswordLayout.error = it?.let { it1 -> getString(it1) } } - watch(life, { it.loggingIn }) { - binding.fragLoginLoginButton.apply { - text = if (it) getString(R.string.logging_in) else getString(R.string.login_title) - isEnabled = !it - } - } - watch(life, { it.loginError }) { - if (it != null) { - Snackbar.make(binding.root, ErrorUtils.describeError(this@LoginFragment, it), Snackbar.LENGTH_LONG).show() - } - } - watch(life, { it.logged }) { - if (it) { - activity?.finish() - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/LoginViewModel.kt b/app/src/main/java/com/fduhole/danxinative/ui/LoginViewModel.kt deleted file mode 100644 index eaa5f21..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/LoginViewModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.fduhole.danxinative.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.fduhole.danxinative.R -import com.fduhole.danxinative.model.PersonInfo -import com.fduhole.danxinative.repository.fdu.EhallRepository -import com.fduhole.danxinative.state.GlobalState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class LoginViewModel : ViewModel(), KoinComponent { - private val _uiState = MutableStateFlow(LoginUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val ehallRepository: EhallRepository by inject() - private val globalState: GlobalState by inject() - fun onIdChanged(x: String) = _uiState.update { it.copy(idErrorId = if (x.isEmpty()) R.string.id_non_empty else null) } - - fun onPasswordChanged(x: String) = _uiState.update { it.copy(passwordErrorId = if (x.isEmpty()) R.string.password_non_empty else null) } - - fun logIn(id: String?, password: String?) { - // If error exists, do not login - onIdChanged(id.orEmpty()) - onPasswordChanged(password.orEmpty()) - if (id.isNullOrEmpty() || password.isNullOrEmpty()) return - - _uiState.update { it.copy(loggingIn = true) } - viewModelScope.launch { - try { - val studentInfo = ehallRepository.getStudentInfo(PersonInfo("", id, password)) - globalState.person = PersonInfo(studentInfo.name!!, id, password) - _uiState.update { it.copy(logged = true) } - } catch (e: Throwable) { - _uiState.update { it.copy(loginError = e) } - } - - _uiState.update { it.copy(loggingIn = false) } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/SettingsFragment.kt b/app/src/main/java/com/fduhole/danxinative/ui/SettingsFragment.kt deleted file mode 100644 index 05a3257..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/SettingsFragment.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.fduhole.danxinative.ui - -import android.content.Intent -import android.os.Bundle -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.fduhole.danxinative.AboutActivity -import com.fduhole.danxinative.BuildConfig -import com.fduhole.danxinative.R -import com.fduhole.danxinative.state.GlobalState -import com.fduhole.danxinative.util.SharedPrefDataStore -import org.koin.android.ext.android.inject - -class SettingsFragment : PreferenceFragmentCompat() { - private val globalState: GlobalState by inject() - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = SharedPrefDataStore(globalState.preferences) - setPreferencesFromResource(R.xml.root_preferences, rootKey) - findPreference("campus_account")?.setSummaryProvider { globalState.person?.toString() } - findPreference("about")?.apply { - setSummaryProvider { "${BuildConfig.VERSION_NAME} (Build ${BuildConfig.VERSION_CODE})" } - setOnPreferenceClickListener { - startActivity(Intent(activity, AboutActivity::class.java)) - true - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/fdu/AAONoticesFragment.kt b/app/src/main/java/com/fduhole/danxinative/ui/fdu/AAONoticesFragment.kt deleted file mode 100644 index 2bc8730..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/fdu/AAONoticesFragment.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.fduhole.danxinative.ui.fdu - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter -import androidx.paging.PagingData -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.fduhole.danxinative.BrowserActivity -import com.fduhole.danxinative.R -import com.fduhole.danxinative.databinding.FragmentAaoNoticesBinding -import com.fduhole.danxinative.databinding.ItemListTileBinding -import com.fduhole.danxinative.databinding.ItemLoadStateBinding -import com.fduhole.danxinative.model.AAONotice -import com.fduhole.danxinative.state.GlobalState -import com.fduhole.danxinative.util.FDULoginUtils.Companion.uisLoginJavaScript -import com.fduhole.danxinative.util.lifecycle.watch -import com.google.android.material.button.MaterialButton -import com.google.android.material.progressindicator.CircularProgressIndicator -import com.google.android.material.textview.MaterialTextView -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -data class AAONoticesUiState( - val flow: Flow>? = null, -) - -class AAONoticesFragment : Fragment(), KoinComponent { - - private val viewModel: AAONoticesViewModel by viewModels() - private val globalState: GlobalState by inject() - private lateinit var binding: FragmentAaoNoticesBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentAaoNoticesBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - (activity as AppCompatActivity).supportActionBar?.title = "教务处通知" - viewModel.initModel() - val pagingAdapter = AAONoticeAdapter({ _, notice -> - notice?.let { - startActivity(Intent(activity, BrowserActivity::class.java) - .putExtra(BrowserActivity.KEY_URL, it.url) - .putExtra(BrowserActivity.KEY_JAVASCRIPT, uisLoginJavaScript(globalState.person!!)) - .putExtra(BrowserActivity.KEY_EXECUTE_IF_START_WITH, "https://uis.fudan.edu.cn/authserver/login")) - } - }, AAONoticeComparator) - - // Why using the identical adapters here as header and footer? - // - // Header is to display a loading indicator (or error) during initial loading, - // and footer is to display an indicator (or error) when loading following pages. - binding.fragAaoNoticesList.adapter = - pagingAdapter.withLoadStateAdapters(header = GeneralLoadStateAdapter(pagingAdapter::retry), - footer = GeneralLoadStateAdapter(pagingAdapter::retry)) - - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.apply { - watch(this@repeatOnLifecycle, { it.flow }) { - lifecycleScope.launch { it?.collectLatest { pagingData -> pagingAdapter.submitData(pagingData) } } - } - } - } - } - } -} - -object AAONoticeComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: AAONotice, newItem: AAONotice): Boolean = oldItem.url == newItem.url - - override fun areContentsTheSame(oldItem: AAONotice, newItem: AAONotice): Boolean = oldItem == newItem -} - -class AAONoticeAdapter(private val onItemClick: (View?, AAONotice?) -> Unit, diffCallback: DiffUtil.ItemCallback) : - PagingDataAdapter(diffCallback) { - inner class AAONoticeViewHolder(private val binding: ItemListTileBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: AAONotice?) { - binding.apply { - mtrlListItemText.text = item?.title - mtrlListItemSecondaryText.text = item?.time - binding.root.setOnClickListener { onItemClick(it, item) } - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AAONoticeViewHolder = - AAONoticeViewHolder(ItemListTileBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - - override fun onBindViewHolder(holder: AAONoticeViewHolder, position: Int) = holder.bind(getItem(position)) - -} - -class LoadStateViewHolder( - parent: ViewGroup, - retry: () -> Unit, -) : RecyclerView.ViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.item_load_state, parent, false) -) { - private val binding = ItemLoadStateBinding.bind(itemView) - private val progressBar: CircularProgressIndicator = binding.itLoadStateProgress - private val errorMsg: MaterialTextView = binding.itLoadStateErrorText - private val retry: MaterialButton = binding.itLoadStateErrorRetryButton - .also { it.setOnClickListener { retry() } } - - fun bind(loadState: LoadState) { - if (loadState is LoadState.Error) { - errorMsg.text = loadState.error.localizedMessage - } - - progressBar.isVisible = loadState is LoadState.Loading - retry.isVisible = loadState is LoadState.Error - errorMsg.isVisible = loadState is LoadState.Error - } -} - -class GeneralLoadStateAdapter( - private val retry: () -> Unit, -) : LoadStateAdapter() { - - override fun onCreateViewHolder( - parent: ViewGroup, - loadState: LoadState, - ) = LoadStateViewHolder(parent, retry) - - override fun onBindViewHolder( - holder: LoadStateViewHolder, - loadState: LoadState, - ) = holder.bind(loadState) -} - -/** - * Return a [ConcatAdapter] connecting [header], [this] paging adapter and [footer]. - * Produce [header] with refreshing states, [footer] with append states. - */ -fun PagingDataAdapter.withLoadStateAdapters( - header: LoadStateAdapter<*>, - footer: LoadStateAdapter<*> -): ConcatAdapter { - addLoadStateListener { loadStates -> - header.loadState = loadStates.refresh - footer.loadState = loadStates.append - } - - return ConcatAdapter(header, this, footer) -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/fdu/AAONoticesViewModel.kt b/app/src/main/java/com/fduhole/danxinative/ui/fdu/AAONoticesViewModel.kt deleted file mode 100644 index 6d69dee..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/fdu/AAONoticesViewModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.fduhole.danxinative.ui.fdu - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.* -import com.fduhole.danxinative.model.AAONotice -import com.fduhole.danxinative.repository.fdu.AAORepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class AAONoticesViewModel : ViewModel() { - private val _uiState = MutableStateFlow(AAONoticesUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - fun initModel() { - _uiState.update { - it.copy( - flow = Pager(PagingConfig(pageSize = 10)) { AAONoticePageSource() } - .flow.cachedIn(viewModelScope) - ) - } - } - -} - -class AAONoticePageSource : PagingSource(), KoinComponent { - private val backend: AAORepository by inject() - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun load(params: LoadParams): LoadResult = try { - val pageNumber = params.key ?: 1 - val response = backend.getNoticeList(pageNumber) - LoadResult.Page(response, nextKey = if (response.isEmpty()) null else pageNumber + 1, prevKey = null) - } catch (e: Throwable) { - LoadResult.Error(e) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/fdu/ECardFragment.kt b/app/src/main/java/com/fduhole/danxinative/ui/fdu/ECardFragment.kt deleted file mode 100644 index e49f634..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/fdu/ECardFragment.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.fduhole.danxinative.ui.fdu - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.paging.PagingData -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.fduhole.danxinative.databinding.FragmentEcardBinding -import com.fduhole.danxinative.databinding.ItemListTileBinding -import com.fduhole.danxinative.model.CardRecord -import com.fduhole.danxinative.util.lifecycle.watch -import com.fduhole.danxinative.util.toDateTimeString -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent - -data class ECardUiState( - val flow: Flow>? = null, -) - -class ECardFragment : Fragment(), KoinComponent { - - private val viewModel: ECardViewModel by viewModels() - private lateinit var binding: FragmentEcardBinding - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentEcardBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - (activity as AppCompatActivity).supportActionBar?.title = "校园卡消费记录" - val pagingAdapter = ECardAdapter(ECardComparator) - viewModel.initModel() - // Why using the identical adapters here as header and footer? - // - // Header is to display a loading indicator (or error) during initial loading, - // and footer is to display an indicator (or error) when loading following pages. - binding.fragEcardList.adapter = - pagingAdapter.withLoadStateAdapters(header = GeneralLoadStateAdapter(pagingAdapter::retry), - footer = GeneralLoadStateAdapter(pagingAdapter::retry)) - - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.apply { - watch(this@repeatOnLifecycle, { it.flow }) { - lifecycleScope.launch { it?.collectLatest { pagingData -> pagingAdapter.submitData(pagingData) } } - } - } - } - } - } -} - -object ECardComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: CardRecord, newItem: CardRecord): Boolean = oldItem.time == newItem.time - - override fun areContentsTheSame(oldItem: CardRecord, newItem: CardRecord): Boolean = oldItem == newItem -} - -class ECardAdapter(diffCallback: DiffUtil.ItemCallback) : - PagingDataAdapter(diffCallback) { - inner class ECardViewHolder(private val binding: ItemListTileBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: CardRecord?) { - binding.apply { - mtrlListItemText.text = item?.location - mtrlListItemSecondaryText.text = item?.time?.toDateTimeString("yyyy-MM-dd HH:mm:ss") - mtrlListItemTrailingIcon.text = item?.payment - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ECardViewHolder = - ECardViewHolder(ItemListTileBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - - override fun onBindViewHolder(holder: ECardViewHolder, position: Int) = holder.bind(getItem(position)) - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/fdu/ECardViewModel.kt b/app/src/main/java/com/fduhole/danxinative/ui/fdu/ECardViewModel.kt deleted file mode 100644 index 1b33d15..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/fdu/ECardViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.fduhole.danxinative.ui.fdu - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.* -import com.fduhole.danxinative.model.CardRecord -import com.fduhole.danxinative.repository.fdu.ECardRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class ECardViewModel : ViewModel() { - private val _uiState = MutableStateFlow(ECardUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun initModel() { - _uiState.update { - it.copy( - flow = Pager(PagingConfig(pageSize = 10)) { ECardPageSource() } - .flow.cachedIn(viewModelScope) - ) - } - } -} - -class ECardPageSource(private val maxDays: Int = 365) : PagingSource(), KoinComponent { - private val backend: ECardRepository by inject() - private var payload: Map? = null - private var totalPageNum: Int? = null - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun load(params: LoadParams): LoadResult = try { - if (payload == null || totalPageNum == null) { - val info = backend.getPagedCardRecordsPayloadAndPageNum(maxDays) - payload = info.first - totalPageNum = info.second - } - val pageNumber = params.key ?: 1 - val response = backend.getPagedCardRecords(payload!!, pageNumber) - LoadResult.Page(response, nextKey = if (pageNumber + 1 > (totalPageNum ?: 0)) null else pageNumber + 1, prevKey = null) - } catch (e: Throwable) { - LoadResult.Error(e) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/fdu/QRCodeFragment.kt b/app/src/main/java/com/fduhole/danxinative/ui/fdu/QRCodeFragment.kt deleted file mode 100644 index 31250e7..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/fdu/QRCodeFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.fduhole.danxinative.ui.fdu - -import android.graphics.Bitmap -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.fduhole.danxinative.databinding.FragmentQrCodeBinding -import com.google.zxing.BarcodeFormat -import com.google.zxing.EncodeHintType -import com.google.zxing.MultiFormatWriter -import com.google.zxing.common.BitMatrix -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import kotlin.coroutines.resume - - -class QRCodeFragment : Fragment() { - - companion object { - const val ARG_QR_CODE = "qr_code" - } - - private var qrCode: String? = null - private lateinit var binding: FragmentQrCodeBinding - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - qrCode = it.getString(ARG_QR_CODE) - } - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentQrCodeBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - (activity as AppCompatActivity).supportActionBar?.title = "复旦生活码" - - try { - val bitmap = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { it.resume(generateQRCode(requireNotNull(qrCode) { "QRCode's data is null!" })) } - } - binding.fragQrCodeQrCode.setImageBitmap(bitmap) - } catch (e: Throwable) { - // TODO: dealing with error when generating bitmap for qr code. - } - } - } - - private fun generateQRCode(data: String): Bitmap { - val matrix = MultiFormatWriter().encode(data, BarcodeFormat.QR_CODE, 200, 200, mutableMapOf( - EncodeHintType.CHARACTER_SET to "utf-8", - EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L, - EncodeHintType.MARGIN to 2 - )) - return toBitmap(matrix) - } - - private fun toBitmap(bitMatrix: BitMatrix): Bitmap { - val width = bitMatrix.width - val height = bitMatrix.height - val pixels = IntArray(width * height) - for (y in 0 until height) { - for (x in 0 until width) { - pixels[y * width + x] = if (bitMatrix[x, y]) -0x1000000 else -0x1 - } - } - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - bitmap.setPixels(pixels, 0, width, 0, 0, width, height) - return bitmap - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/opentreehole/BBSFragment.kt b/app/src/main/java/com/fduhole/danxinative/ui/opentreehole/BBSFragment.kt deleted file mode 100644 index b386ed1..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/opentreehole/BBSFragment.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.fduhole.danxinative.ui.opentreehole - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.fduhole.danxinative.databinding.FragmentBbsBinding - -class BBSFragment : Fragment() { - - private val viewModel: BBSViewModel by viewModels() - private lateinit var binding: FragmentBbsBinding - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentBbsBinding.inflate(inflater, container, false) - return binding.root - } - - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/ui/opentreehole/BBSViewModel.kt b/app/src/main/java/com/fduhole/danxinative/ui/opentreehole/BBSViewModel.kt deleted file mode 100644 index b87bb3b..0000000 --- a/app/src/main/java/com/fduhole/danxinative/ui/opentreehole/BBSViewModel.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.fduhole.danxinative.ui.opentreehole - -import androidx.lifecycle.ViewModel - -class BBSViewModel : ViewModel() { - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/util/ErrorUtils.kt b/app/src/main/java/com/fduhole/danxinative/util/ErrorUtils.kt deleted file mode 100644 index 945f851..0000000 --- a/app/src/main/java/com/fduhole/danxinative/util/ErrorUtils.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.fduhole.danxinative.util - -import android.content.Context -import android.content.res.Resources -import androidx.fragment.app.Fragment - -/* -An exception that is, given string resources, able to explain itself with localized messages. -* */ -abstract class ExplainableException : Exception() { - abstract fun explain(context: Resources): String -} - -class ErrorUtils { - companion object { - fun describeError(context: Context, error: Throwable): String = describeError(context.resources, error) - - fun describeError(context: Fragment, error: Throwable): String = describeError(context.resources, error) - - fun describeError(context: Resources, error: Throwable): String { - if (error is ExplainableException) { - return error.explain(context) - } - return error.localizedMessage ?: error.message ?: error.toString() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/util/SharedPrefDataStore.kt b/app/src/main/java/com/fduhole/danxinative/util/SharedPrefDataStore.kt deleted file mode 100644 index 8caaa24..0000000 --- a/app/src/main/java/com/fduhole/danxinative/util/SharedPrefDataStore.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.fduhole.danxinative.util - -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.preference.PreferenceDataStore - -class SharedPrefDataStore(private val sharedPreferences: SharedPreferences) : PreferenceDataStore() { - override fun putString(key: String?, value: String?) = sharedPreferences.edit { putString(key, value) } - - override fun putStringSet(key: String?, values: MutableSet?) = sharedPreferences.edit { putStringSet(key, values) } - - override fun putInt(key: String?, value: Int) = sharedPreferences.edit { putInt(key, value) } - - override fun putLong(key: String?, value: Long) = sharedPreferences.edit { putLong(key, value) } - - override fun putFloat(key: String?, value: Float) = sharedPreferences.edit { putFloat(key, value) } - - override fun putBoolean(key: String?, value: Boolean) = sharedPreferences.edit { putBoolean(key, value) } - - override fun getString(key: String?, defValue: String?): String? = sharedPreferences.getString(key, defValue) - - override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = sharedPreferences.getStringSet(key, defValues) - - override fun getInt(key: String?, defValue: Int): Int = sharedPreferences.getInt(key, defValue) - - override fun getLong(key: String?, defValue: Long): Long = sharedPreferences.getLong(key, defValue) - - override fun getFloat(key: String?, defValue: Float): Float = sharedPreferences.getFloat(key, defValue) - - override fun getBoolean(key: String?, defValue: Boolean): Boolean = sharedPreferences.getBoolean(key, defValue) -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/util/lifecycle/LifeCycleUtils.kt b/app/src/main/java/com/fduhole/danxinative/util/lifecycle/LifeCycleUtils.kt deleted file mode 100644 index 79b79bc..0000000 --- a/app/src/main/java/com/fduhole/danxinative/util/lifecycle/LifeCycleUtils.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.fduhole.danxinative.util.lifecycle - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -inline fun StateFlow.watch(scope: CoroutineScope, crossinline selector: suspend (value: T) -> R, collector: FlowCollector): Job = - scope.launch { map(selector).distinctUntilChanged().collect(collector) } \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/util/net/MemoryCookieJar.kt b/app/src/main/java/com/fduhole/danxinative/util/net/MemoryCookieJar.kt deleted file mode 100644 index 93ef227..0000000 --- a/app/src/main/java/com/fduhole/danxinative/util/net/MemoryCookieJar.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.fduhole.danxinative.util.net - -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl -import okhttp3.JavaNetCookieJar - -/// A cookie jar stored in memory, supporting replacing its content with another cookieJar. -class MemoryCookieJar(var javaNetCookieJar: JavaNetCookieJar) : CookieJar { - override fun loadForRequest(url: HttpUrl): List = javaNetCookieJar.loadForRequest(url) - - override fun saveFromResponse(url: HttpUrl, cookies: List) = javaNetCookieJar.saveFromResponse(url, cookies) - - fun replaceBy(javaNetCookieJar: JavaNetCookieJar) { - this.javaNetCookieJar = javaNetCookieJar - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/fduhole/danxinative/util/net/RetryUtils.kt b/app/src/main/java/com/fduhole/danxinative/util/net/RetryUtils.kt deleted file mode 100644 index 7aa424f..0000000 --- a/app/src/main/java/com/fduhole/danxinative/util/net/RetryUtils.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.fduhole.danxinative.util.net - -class RetryUtils { -} - -data class RetryCount( - val retryTime: Int = 0, -) \ No newline at end of file diff --git a/app/src/main/res/color/mtrl_list_item_tint.xml b/app/src/main/res/color/mtrl_list_item_tint.xml deleted file mode 100644 index 3050111..0000000 --- a/app/src/main/res/color/mtrl_list_item_tint.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d1..0000000 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_accessibility_new_24.xml b/app/src/main/res/drawable/ic_baseline_accessibility_new_24.xml deleted file mode 100644 index 18e167d..0000000 --- a/app/src/main/res/drawable/ic_baseline_accessibility_new_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_android_24.xml b/app/src/main/res/drawable/ic_baseline_android_24.xml deleted file mode 100644 index 01975bc..0000000 --- a/app/src/main/res/drawable/ic_baseline_android_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_dashboard_24.xml b/app/src/main/res/drawable/ic_baseline_dashboard_24.xml deleted file mode 100644 index ee77e6b..0000000 --- a/app/src/main/res/drawable/ic_baseline_dashboard_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_forum_24.xml b/app/src/main/res/drawable/ic_baseline_forum_24.xml deleted file mode 100644 index 9bd0af8..0000000 --- a/app/src/main/res/drawable/ic_baseline_forum_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_library_add_check_24.xml b/app/src/main/res/drawable/ic_baseline_library_add_check_24.xml deleted file mode 100644 index 8e03f8a..0000000 --- a/app/src/main/res/drawable/ic_baseline_library_add_check_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_newspaper_24.xml b/app/src/main/res/drawable/ic_baseline_newspaper_24.xml deleted file mode 100644 index 500c31e..0000000 --- a/app/src/main/res/drawable/ic_baseline_newspaper_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_password_24.xml b/app/src/main/res/drawable/ic_baseline_password_24.xml deleted file mode 100644 index 023661e..0000000 --- a/app/src/main/res/drawable/ic_baseline_password_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_payment_24.xml b/app/src/main/res/drawable/ic_baseline_payment_24.xml deleted file mode 100644 index 5b02501..0000000 --- a/app/src/main/res/drawable/ic_baseline_payment_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_person_24.xml b/app/src/main/res/drawable/ic_baseline_person_24.xml deleted file mode 100644 index 4ccc03f..0000000 --- a/app/src/main/res/drawable/ic_baseline_person_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_qr_code_24.xml b/app/src/main/res/drawable/ic_baseline_qr_code_24.xml deleted file mode 100644 index 5461a42..0000000 --- a/app/src/main/res/drawable/ic_baseline_qr_code_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml deleted file mode 100644 index f8aae22..0000000 --- a/app/src/main/res/drawable/ic_baseline_settings_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/shape_round_tag.xml b/app/src/main/res/drawable/shape_round_tag.xml deleted file mode 100644 index f731062..0000000 --- a/app/src/main/res/drawable/shape_round_tag.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_browser.xml b/app/src/main/res/layout/activity_browser.xml deleted file mode 100644 index 88021b5..0000000 --- a/app/src/main/res/layout/activity_browser.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml deleted file mode 100644 index 183062b..0000000 --- a/app/src/main/res/layout/activity_login.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index f60ad6e..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_single_fragment.xml b/app/src/main/res/layout/activity_single_fragment.xml deleted file mode 100644 index 689fd88..0000000 --- a/app/src/main/res/layout/activity_single_fragment.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 740fd45..0000000 --- a/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_aao_notices.xml b/app/src/main/res/layout/fragment_aao_notices.xml deleted file mode 100644 index 84b8436..0000000 --- a/app/src/main/res/layout/fragment_aao_notices.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_bbs.xml b/app/src/main/res/layout/fragment_bbs.xml deleted file mode 100644 index 0b15475..0000000 --- a/app/src/main/res/layout/fragment_bbs.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_ecard.xml b/app/src/main/res/layout/fragment_ecard.xml deleted file mode 100644 index 9da57b5..0000000 --- a/app/src/main/res/layout/fragment_ecard.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml deleted file mode 100644 index c38b776..0000000 --- a/app/src/main/res/layout/fragment_home.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml deleted file mode 100644 index 7d36c7c..0000000 --- a/app/src/main/res/layout/fragment_login.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_qr_code.xml b/app/src/main/res/layout/fragment_qr_code.xml deleted file mode 100644 index 8199e5f..0000000 --- a/app/src/main/res/layout/fragment_qr_code.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_feature_card.xml b/app/src/main/res/layout/item_feature_card.xml deleted file mode 100644 index ee808d8..0000000 --- a/app/src/main/res/layout/item_feature_card.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_hole_card.xml b/app/src/main/res/layout/item_hole_card.xml deleted file mode 100644 index 1fb7e17..0000000 --- a/app/src/main/res/layout/item_hole_card.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_list_tile.xml b/app/src/main/res/layout/item_list_tile.xml deleted file mode 100644 index db30a02..0000000 --- a/app/src/main/res/layout/item_list_tile.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_load_state.xml b/app/src/main/res/layout/item_load_state.xml deleted file mode 100644 index fc899d0..0000000 --- a/app/src/main/res/layout/item_load_state.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml deleted file mode 100644 index dcea1d7..0000000 --- a/app/src/main/res/menu/menu_main.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index 51c7be7..0000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..c8333ef --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=zh \ No newline at end of file diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml deleted file mode 100644 index 22d7f00..0000000 --- a/app/src/main/res/values-land/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml deleted file mode 100644 index a758b25..0000000 --- a/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/values-v29/themes.xml b/app/src/main/res/values-v29/themes.xml deleted file mode 100644 index fa6cfac..0000000 --- a/app/src/main/res/values-v29/themes.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/values-w1240dp/dimens.xml b/app/src/main/res/values-w1240dp/dimens.xml deleted file mode 100644 index d73f4a3..0000000 --- a/app/src/main/res/values-w1240dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 200dp - \ No newline at end of file diff --git a/app/src/main/res/values-w600dp/dimens.xml b/app/src/main/res/values-w600dp/dimens.xml deleted file mode 100644 index 22d7f00..0000000 --- a/app/src/main/res/values-w600dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 98b064f..7548fcd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2,7 +2,7 @@ 旦夕 ID - 登录 + 登录 使用学校身份信息登录旦夕 登录中 服务器错误,认为您所输入的是弱密码。请重试数次,或修改密码后再登录。 @@ -12,9 +12,8 @@ ID 不能为空 密码不能为空 设置 - [...] 主页 - 密码 + 密码 登陆或注册新账号 登陆 用户名格式不正确 @@ -28,4 +27,34 @@ 简介 开发者 开源软件许可协议 + 关于 + 取消 + 密码 + 学号 + 复旦 UIS 登录 + 主页 + 日程 + 树洞 + 课评 + 版本 + 点击以查看 + 加载失败 + 教务处通知 + 校园卡余额 + 图书馆人数 + 复旦生活码 + + 正在加载复活码…\n可能需要 5~15 秒,这取决于复旦服务器。 + 重试 + 加载中… + 语言 + 主题 + 接受 + 拒绝 + 英语 + 简体中文 + 浅色 + 深色 + 跟随系统 + 我们向您请求大致位置权限,是因为当前页面正在调用 Geolocation API 来获知您的大致位置。 \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml deleted file mode 100644 index 6cf9ed4..0000000 --- a/app/src/main/res/values/arrays.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - Reply - Reply to all - - - - reply - reply_all - - \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index c8524cd..0000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - #FF000000 - #FFFFFFFF - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 409f72e..0000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,6 +0,0 @@ - - 16dp - - 16dp - 16dp - \ 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 d84580a..00a5781 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,11 +1,7 @@ DanXi Settings - - Next - Previous - - + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a, ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum @@ -27,23 +23,7 @@ luctus vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim. Home - - Messages - Sync - - - Your signature - Default reply action - - - Sync email periodically - Download incoming attachments - Automatically download attachments for incoming emails - Only download attachments when manually requested - Settings - - Email - Password + Password Sign in or register Sign in "Welcome!" @@ -51,11 +31,7 @@ Password must be >5 characters "Login failed" ID - Login - LoginActivity - - First Fragment - Second Fragment + Login Login with your UIS account Logging in Server error: weak password detected. Please retry or change your password. @@ -66,10 +42,39 @@ Password cannot be null 服务器错误。数次尝试后仍未从服务器获得有效的响应,请重试。 Treehole - 由几位复旦本科学生用爱发电打造的微型复旦综合服务 App,希望能为你的生活提供便利~ - 简介 - 开发者 - 开源软件许可协议 - - Hello blank fragment + A miniature Fudan integrated service app, created by several Fudan undergraduate students with love. We hope it can facilitate your life~ + Description + Developer + License + About + Cancel + Password + ID + Fudan UIS Login + Dashboard + Timetable + FDUHole + Settings + Course + Version + Tap to view + Failed to load + Academic Announcements + ECard Balance + Library Attendance + Fudan QR Code + OK + Loading Fudan QR Code…\nThis may take 5–10 seconds, depending on Fudan servers. + Retry + Loading… + Language + Theme + Accept + Deny + English + Simplified Chinese + Light + Dark + System + We request you for the approximate location permission because the current page is calling the Geolocation API to know your approximate location. \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 96ef6db..39366df 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,13 +1,3 @@ - - - - -