init: 河南女子职业学院智慧学工考勤签到 Android 应用
功能包含:学生登录、微信授权、自定义GPS签到、预设管理、历史记录 开发者:凡笙 Made-with: Cursor
This commit is contained in:
48
app/build.gradle
Normal file
48
app/build.gradle
Normal file
@@ -0,0 +1,48 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.gpspunch.app'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.gpspunch.app"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'org.jsoup:jsoup:1.17.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
7
app/proguard-rules.pro
vendored
Normal file
7
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
|
||||
# Jsoup
|
||||
-keep class org.jsoup.** { *; }
|
||||
28
app/src/main/AndroidManifest.xml
Normal file
28
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.GPSPunch"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
521
app/src/main/java/com/gpspunch/app/MainActivity.kt
Normal file
521
app/src/main/java/com/gpspunch/app/MainActivity.kt
Normal file
@@ -0,0 +1,521 @@
|
||||
package com.gpspunch.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.gpspunch.app.databinding.ActivityMainBinding
|
||||
import kotlinx.coroutines.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val api = PunchApi()
|
||||
private lateinit var prefs: PrefsManager
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
prefs = PrefsManager(this)
|
||||
|
||||
setupLogin()
|
||||
setupPunch()
|
||||
setupGps()
|
||||
setupHistory()
|
||||
setupNav()
|
||||
showDisclaimerIfNeeded()
|
||||
}
|
||||
|
||||
private fun showDisclaimerIfNeeded() {
|
||||
if (prefs.isDisclaimerAccepted()) {
|
||||
checkSavedLogin()
|
||||
return
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("安全提示")
|
||||
.setMessage(
|
||||
"本应用仅供参考学习使用。\n\n" +
|
||||
"如果您使用本应用的相关功能,即代表您自愿放弃河南女子职业学院相关规章制度所赋予的权益," +
|
||||
"并自行承担一切后果。\n\n" +
|
||||
"本 App 未加入任何第三方接口,也未收集您的任何个人信息。\n" +
|
||||
"全部使用的接口如下:\n" +
|
||||
"• https://wx703c2206450720dd.g8n.cn\n" +
|
||||
"• https://login.b8n.cn\n\n" +
|
||||
"开发者:凡笙\n\n" +
|
||||
"请确认您已知晓以上内容。"
|
||||
)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("我同意并知晓") { _, _ ->
|
||||
prefs.setDisclaimerAccepted()
|
||||
checkSavedLogin()
|
||||
}
|
||||
.setNegativeButton("我拒绝") { _, _ ->
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
|
||||
private fun setupNav() {
|
||||
binding.bottomNav.setOnItemSelectedListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.nav_punch -> showView(binding.punchView)
|
||||
R.id.nav_gps -> showView(binding.gpsView)
|
||||
R.id.nav_history -> {
|
||||
showView(binding.historyView)
|
||||
renderHistory()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun showView(target: View) {
|
||||
binding.punchView.visibility = View.GONE
|
||||
binding.gpsView.visibility = View.GONE
|
||||
binding.historyView.visibility = View.GONE
|
||||
target.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun showLogin() {
|
||||
binding.loginView.visibility = View.VISIBLE
|
||||
binding.guideView.visibility = View.GONE
|
||||
binding.mainContent.visibility = View.GONE
|
||||
binding.bottomNav.visibility = View.GONE
|
||||
binding.headerUserBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showGuide(qrUrl: String) {
|
||||
binding.loginView.visibility = View.GONE
|
||||
binding.guideView.visibility = View.VISIBLE
|
||||
binding.mainContent.visibility = View.GONE
|
||||
binding.bottomNav.visibility = View.GONE
|
||||
binding.headerUserBar.visibility = View.GONE
|
||||
binding.tvWeixinLink.text = qrUrl
|
||||
}
|
||||
|
||||
private fun showApp() {
|
||||
binding.loginView.visibility = View.GONE
|
||||
binding.guideView.visibility = View.GONE
|
||||
binding.mainContent.visibility = View.VISIBLE
|
||||
binding.bottomNav.visibility = View.VISIBLE
|
||||
binding.headerUserBar.visibility = View.VISIBLE
|
||||
binding.bottomNav.selectedItemId = R.id.nav_punch
|
||||
showView(binding.punchView)
|
||||
loadGpsPresets()
|
||||
loadCurrentGps()
|
||||
}
|
||||
|
||||
// ── Login ──
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
private var captchaInited = false
|
||||
|
||||
private fun setupLogin() {
|
||||
binding.captchaImg.setOnClickListener { refreshCaptcha() }
|
||||
binding.btnLogin.setOnClickListener { doLogin() }
|
||||
binding.btnCookieLogin.setOnClickListener { doCookieLogin() }
|
||||
binding.btnCopyLink.setOnClickListener { copyWeixinLink() }
|
||||
binding.btnBackToLogin.setOnClickListener {
|
||||
pollingJob?.cancel()
|
||||
showLogin()
|
||||
refreshCaptcha()
|
||||
}
|
||||
refreshCaptcha()
|
||||
}
|
||||
|
||||
private fun checkSavedLogin() {
|
||||
val s = prefs.getCookieS()
|
||||
val remember = prefs.getCookieRemember()
|
||||
if (s.isNotEmpty()) {
|
||||
scope.launch {
|
||||
val r = withContext(Dispatchers.IO) { api.cookieLogin(s, remember) }
|
||||
if (r.ok) {
|
||||
binding.tvUser.text = r.userName
|
||||
prefs.saveUser(r.userName)
|
||||
showApp()
|
||||
} else {
|
||||
showLogin()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showLogin()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCaptcha() {
|
||||
scope.launch {
|
||||
try {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
if (captchaInited) api.refreshCaptchaOnly() else api.getCaptchaBytes()
|
||||
}
|
||||
captchaInited = true
|
||||
val bmp = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (bmp != null) binding.captchaImg.setImageBitmap(bmp)
|
||||
} catch (_: Exception) {
|
||||
toast("验证码加载失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doLogin() {
|
||||
val name = binding.etName.text.toString().trim()
|
||||
val no = binding.etNo.text.toString().trim()
|
||||
val code = binding.etCode.text.toString().trim()
|
||||
|
||||
if (name.isEmpty() || no.isEmpty() || code.isEmpty()) {
|
||||
toast("请填写完整信息")
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.btnLogin.text = "登录中…"
|
||||
|
||||
scope.launch {
|
||||
val r = withContext(Dispatchers.IO) { api.loginAndGetQr(name, no, code) }
|
||||
binding.btnLogin.isEnabled = true
|
||||
binding.btnLogin.text = "登 录"
|
||||
|
||||
if (r.ok) {
|
||||
toast("请复制链接发送到微信")
|
||||
showGuide(r.qrUrl)
|
||||
startPolling()
|
||||
} else {
|
||||
toast(r.msg)
|
||||
refreshCaptcha()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyWeixinLink() {
|
||||
val link = binding.tvWeixinLink.text.toString()
|
||||
if (link.isEmpty()) return
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("login_link", link))
|
||||
toast("已复制!请发送到微信「文件传输助手」并打开")
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
pollingJob?.cancel()
|
||||
binding.tvPollingStatus.visibility = View.VISIBLE
|
||||
binding.tvPollingStatus.text = "等待微信确认登录…"
|
||||
|
||||
pollingJob = scope.launch {
|
||||
var attempts = 0
|
||||
while (attempts < 30) {
|
||||
delay(2000)
|
||||
attempts++
|
||||
binding.tvPollingStatus.text = "等待微信确认登录… ($attempts)"
|
||||
|
||||
try {
|
||||
val r = withContext(Dispatchers.IO) { api.checkWeixinLogin() }
|
||||
if (r.ok) {
|
||||
binding.tvPollingStatus.text = "登录成功,正在跳转…"
|
||||
val loginResult = withContext(Dispatchers.IO) {
|
||||
api.completeWeixinLogin(r.redirectUrl)
|
||||
}
|
||||
if (loginResult.ok) {
|
||||
toast("登录成功")
|
||||
prefs.saveCookies(api.getSavedCookieS(), api.getSavedCookieRemember())
|
||||
prefs.saveUser(loginResult.userName)
|
||||
binding.tvUser.text = loginResult.userName
|
||||
showApp()
|
||||
} else {
|
||||
toast("登录跳转失败: ${loginResult.msg}")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
binding.tvPollingStatus.text = "链接已过期,请重新登录"
|
||||
toast("登录超时")
|
||||
}
|
||||
}
|
||||
|
||||
private fun doCookieLogin() {
|
||||
val s = binding.etCookieS.text.toString().trim()
|
||||
if (s.isEmpty()) {
|
||||
toast("请填写 s cookie")
|
||||
return
|
||||
}
|
||||
val remember = binding.etCookieRemember.text.toString().trim()
|
||||
|
||||
binding.btnCookieLogin.isEnabled = false
|
||||
scope.launch {
|
||||
val r = withContext(Dispatchers.IO) { api.cookieLogin(s, remember) }
|
||||
binding.btnCookieLogin.isEnabled = true
|
||||
|
||||
if (r.ok) {
|
||||
toast("登录成功")
|
||||
prefs.saveCookies(s, remember)
|
||||
prefs.saveUser(r.userName)
|
||||
binding.tvUser.text = r.userName
|
||||
showApp()
|
||||
} else {
|
||||
toast(r.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Punch ──
|
||||
|
||||
private fun setupPunch() {
|
||||
binding.btnRefresh.setOnClickListener { refreshPunchs() }
|
||||
binding.btnLogout.setOnClickListener { doLogout() }
|
||||
}
|
||||
|
||||
private fun doLogout() {
|
||||
pollingJob?.cancel()
|
||||
api.clearCookies()
|
||||
prefs.clearLogin()
|
||||
captchaInited = false
|
||||
showLogin()
|
||||
refreshCaptcha()
|
||||
toast("已退出")
|
||||
}
|
||||
|
||||
private fun refreshPunchs() {
|
||||
binding.punchListContainer.removeAllViews()
|
||||
addLoadingView(binding.punchListContainer)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val courses = withContext(Dispatchers.IO) { api.getCourses() }
|
||||
binding.punchListContainer.removeAllViews()
|
||||
|
||||
if (courses.isEmpty()) {
|
||||
addEmptyView(binding.punchListContainer, "未找到课程")
|
||||
return@launch
|
||||
}
|
||||
|
||||
var hasPunch = false
|
||||
for (course in courses) {
|
||||
val punchs = withContext(Dispatchers.IO) { api.getPunchs(course.id) }
|
||||
if (punchs.isEmpty()) continue
|
||||
hasPunch = true
|
||||
for (p in punchs) {
|
||||
addPunchCard(binding.punchListContainer, course, p)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPunch) {
|
||||
addEmptyView(binding.punchListContainer, "暂无进行中的签到")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
binding.punchListContainer.removeAllViews()
|
||||
addEmptyView(binding.punchListContainer, "加载失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addPunchCard(container: ViewGroup, course: CourseInfo, punch: PunchInfo) {
|
||||
val card = LayoutInflater.from(this).inflate(R.layout.item_punch, container, false)
|
||||
card.findViewById<TextView>(R.id.tvPunchTitle).text = punch.title
|
||||
card.findViewById<TextView>(R.id.tvPunchMeta).text = "${course.title} · ${punch.meta}"
|
||||
|
||||
val tvStatus = card.findViewById<TextView>(R.id.tvPunchStatus)
|
||||
tvStatus.text = punch.status
|
||||
when {
|
||||
"已签" in punch.status || "已结束" in punch.status -> {
|
||||
tvStatus.setBackgroundResource(R.drawable.bg_pill_success)
|
||||
tvStatus.setTextColor(Color.parseColor("#21b26f"))
|
||||
}
|
||||
"进行" in punch.status || "已开始" in punch.status -> {
|
||||
tvStatus.setBackgroundResource(R.drawable.bg_pill_primary)
|
||||
tvStatus.setTextColor(Color.parseColor("#4f6ef7"))
|
||||
}
|
||||
else -> {
|
||||
tvStatus.setBackgroundResource(R.drawable.bg_pill_muted)
|
||||
tvStatus.setTextColor(Color.parseColor("#8a94a6"))
|
||||
}
|
||||
}
|
||||
|
||||
card.setOnClickListener {
|
||||
if (punch.id.isNullOrEmpty()) {
|
||||
toast("签到未开始或无法获取 ID")
|
||||
return@setOnClickListener
|
||||
}
|
||||
confirmPunch(course.id, punch.id, punch.title)
|
||||
}
|
||||
|
||||
container.addView(card)
|
||||
}
|
||||
|
||||
private fun confirmPunch(courseId: String, punchId: String, title: String) {
|
||||
val lat = binding.etLat.text.toString().toDoubleOrNull()
|
||||
val lng = binding.etLng.text.toString().toDoubleOrNull()
|
||||
|
||||
if (lat == null || lng == null) {
|
||||
toast("请先设置 GPS 坐标")
|
||||
return
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("确认签到")
|
||||
.setMessage("签到「$title」\n坐标: ($lat, $lng)")
|
||||
.setPositiveButton("签到") { _, _ ->
|
||||
executePunch(courseId, punchId, title, lat, lng)
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun executePunch(courseId: String, punchId: String, title: String, lat: Double, lng: Double) {
|
||||
scope.launch {
|
||||
try {
|
||||
val r = withContext(Dispatchers.IO) { api.doPunch(courseId, punchId, lat, lng) }
|
||||
if (r.ok) {
|
||||
toast("✓ ${r.msg}")
|
||||
} else {
|
||||
toast("✗ ${r.msg}")
|
||||
}
|
||||
val now = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(Date())
|
||||
prefs.addHistory(PunchRecord(title, lat, lng, r.ok, r.msg, now))
|
||||
refreshPunchs()
|
||||
} catch (e: Exception) {
|
||||
toast("网络错误: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── GPS Settings ──
|
||||
|
||||
private fun setupGps() {
|
||||
binding.btnAddPreset.setOnClickListener { addGpsPreset() }
|
||||
}
|
||||
|
||||
private fun loadCurrentGps() {
|
||||
val presets = prefs.getPresets()
|
||||
if (presets.isNotEmpty()) {
|
||||
binding.etLat.setText(presets[0].lat.toString())
|
||||
binding.etLng.setText(presets[0].lng.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadGpsPresets() {
|
||||
val presets = prefs.getPresets()
|
||||
|
||||
// Chips in punch view
|
||||
binding.chipGroupPresets.removeAllViews()
|
||||
for ((i, p) in presets.withIndex()) {
|
||||
val chip = Chip(this).apply {
|
||||
text = p.name
|
||||
isCheckable = true
|
||||
setOnClickListener {
|
||||
binding.etLat.setText(p.lat.toString())
|
||||
binding.etLng.setText(p.lng.toString())
|
||||
toast("已切换: ${p.name}")
|
||||
}
|
||||
}
|
||||
binding.chipGroupPresets.addView(chip)
|
||||
}
|
||||
|
||||
// List in GPS view
|
||||
binding.presetListContainer.removeAllViews()
|
||||
for ((i, p) in presets.withIndex()) {
|
||||
val row = LayoutInflater.from(this).inflate(R.layout.item_preset, binding.presetListContainer, false)
|
||||
row.findViewById<TextView>(R.id.tvPresetName).text = p.name
|
||||
row.findViewById<TextView>(R.id.tvPresetCoord).text = "${p.lat}, ${p.lng}"
|
||||
row.findViewById<View>(R.id.btnDeletePreset).setOnClickListener {
|
||||
prefs.removePreset(i)
|
||||
loadGpsPresets()
|
||||
toast("已删除")
|
||||
}
|
||||
binding.presetListContainer.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addGpsPreset() {
|
||||
val name = binding.etPresetName.text.toString().trim()
|
||||
val lat = binding.etPresetLat.text.toString().toDoubleOrNull()
|
||||
val lng = binding.etPresetLng.text.toString().toDoubleOrNull()
|
||||
|
||||
if (name.isEmpty()) { toast("请输入位置名称"); return }
|
||||
if (lat == null || lng == null) { toast("请输入有效坐标"); return }
|
||||
|
||||
prefs.addPreset(GpsPreset(name, lat, lng))
|
||||
loadGpsPresets()
|
||||
binding.etPresetName.text?.clear()
|
||||
binding.etPresetLat.text?.clear()
|
||||
binding.etPresetLng.text?.clear()
|
||||
toast("位置已保存")
|
||||
}
|
||||
|
||||
// ── History ──
|
||||
|
||||
private fun setupHistory() {}
|
||||
|
||||
private fun renderHistory() {
|
||||
binding.historyListContainer.removeAllViews()
|
||||
val list = prefs.getHistory()
|
||||
if (list.isEmpty()) {
|
||||
addEmptyView(binding.historyListContainer, "暂无签到记录")
|
||||
return
|
||||
}
|
||||
for (r in list) {
|
||||
val card = LayoutInflater.from(this).inflate(R.layout.item_history, binding.historyListContainer, false)
|
||||
card.findViewById<TextView>(R.id.tvHistoryTitle).text = r.title
|
||||
card.findViewById<TextView>(R.id.tvHistoryTime).text = "${r.time} · (${r.lat}, ${r.lng})"
|
||||
val tvResult = card.findViewById<TextView>(R.id.tvHistoryResult)
|
||||
tvResult.text = r.msg
|
||||
if (r.ok) {
|
||||
tvResult.setBackgroundResource(R.drawable.bg_pill_success)
|
||||
tvResult.setTextColor(Color.parseColor("#21b26f"))
|
||||
} else {
|
||||
tvResult.setBackgroundResource(R.drawable.bg_pill_danger)
|
||||
tvResult.setTextColor(Color.parseColor("#ff4d4f"))
|
||||
}
|
||||
binding.historyListContainer.addView(card)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private fun toast(msg: String) {
|
||||
Snackbar.make(binding.root, msg, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun addLoadingView(container: ViewGroup) {
|
||||
val pb = ProgressBar(this).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
if (this is LinearLayout.LayoutParams) gravity = android.view.Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
}
|
||||
val wrapper = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = android.view.Gravity.CENTER
|
||||
setPadding(0, 64, 0, 64)
|
||||
addView(pb)
|
||||
}
|
||||
container.addView(wrapper)
|
||||
}
|
||||
|
||||
private fun addEmptyView(container: ViewGroup, text: String) {
|
||||
val tv = TextView(this).apply {
|
||||
this.text = text
|
||||
setTextColor(Color.parseColor("#8a94a6"))
|
||||
textSize = 14f
|
||||
gravity = android.view.Gravity.CENTER
|
||||
setPadding(32, 64, 32, 64)
|
||||
}
|
||||
container.addView(tv)
|
||||
}
|
||||
}
|
||||
109
app/src/main/java/com/gpspunch/app/PrefsManager.kt
Normal file
109
app/src/main/java/com/gpspunch/app/PrefsManager.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.gpspunch.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class GpsPreset(val name: String, val lat: Double, val lng: Double)
|
||||
data class PunchRecord(
|
||||
val title: String, val lat: Double, val lng: Double,
|
||||
val ok: Boolean, val msg: String, val time: String
|
||||
)
|
||||
|
||||
class PrefsManager(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("gps_punch", Context.MODE_PRIVATE)
|
||||
|
||||
fun saveCookies(s: String, remember: String) {
|
||||
prefs.edit().putString("cookie_s", s).putString("cookie_remember", remember).apply()
|
||||
}
|
||||
|
||||
fun getCookieS(): String = prefs.getString("cookie_s", "") ?: ""
|
||||
fun getCookieRemember(): String = prefs.getString("cookie_remember", "") ?: ""
|
||||
|
||||
fun saveUser(name: String) {
|
||||
prefs.edit().putString("user_name", name).apply()
|
||||
}
|
||||
|
||||
fun getUserName(): String = prefs.getString("user_name", "") ?: ""
|
||||
|
||||
fun clearLogin() {
|
||||
prefs.edit()
|
||||
.remove("cookie_s").remove("cookie_remember").remove("user_name")
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun isDisclaimerAccepted(): Boolean = prefs.getBoolean("disclaimer_accepted", false)
|
||||
|
||||
fun setDisclaimerAccepted() {
|
||||
prefs.edit().putBoolean("disclaimer_accepted", true).apply()
|
||||
}
|
||||
|
||||
fun getPresets(): MutableList<GpsPreset> {
|
||||
val json = prefs.getString("gps_presets", null) ?: return mutableListOf(
|
||||
GpsPreset("默认位置", 0.0, 0.0)
|
||||
)
|
||||
val arr = JSONArray(json)
|
||||
val list = mutableListOf<GpsPreset>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
list.add(GpsPreset(o.getString("name"), o.getDouble("lat"), o.getDouble("lng")))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun savePresets(list: List<GpsPreset>) {
|
||||
val arr = JSONArray()
|
||||
for (p in list) {
|
||||
arr.put(JSONObject().put("name", p.name).put("lat", p.lat).put("lng", p.lng))
|
||||
}
|
||||
prefs.edit().putString("gps_presets", arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun addPreset(preset: GpsPreset) {
|
||||
val list = getPresets()
|
||||
list.add(preset)
|
||||
savePresets(list)
|
||||
}
|
||||
|
||||
fun removePreset(index: Int) {
|
||||
val list = getPresets()
|
||||
if (index in list.indices) {
|
||||
list.removeAt(index)
|
||||
savePresets(list)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHistory(): MutableList<PunchRecord> {
|
||||
val json = prefs.getString("punch_history", null) ?: return mutableListOf()
|
||||
val arr = JSONArray(json)
|
||||
val list = mutableListOf<PunchRecord>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
list.add(
|
||||
PunchRecord(
|
||||
o.getString("title"), o.getDouble("lat"), o.getDouble("lng"),
|
||||
o.getBoolean("ok"), o.getString("msg"), o.getString("time")
|
||||
)
|
||||
)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun addHistory(record: PunchRecord) {
|
||||
val list = getHistory()
|
||||
list.add(0, record)
|
||||
if (list.size > 50) list.subList(50, list.size).clear()
|
||||
val arr = JSONArray()
|
||||
for (r in list) {
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("title", r.title).put("lat", r.lat).put("lng", r.lng)
|
||||
.put("ok", r.ok).put("msg", r.msg).put("time", r.time)
|
||||
)
|
||||
}
|
||||
prefs.edit().putString("punch_history", arr.toString()).apply()
|
||||
}
|
||||
}
|
||||
390
app/src/main/java/com/gpspunch/app/PunchApi.kt
Normal file
390
app/src/main/java/com/gpspunch/app/PunchApi.kt
Normal file
@@ -0,0 +1,390 @@
|
||||
package com.gpspunch.app
|
||||
|
||||
import okhttp3.*
|
||||
import org.jsoup.Jsoup
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class LoginResult(val ok: Boolean, val msg: String, val userName: String = "")
|
||||
data class CourseInfo(val id: String, val title: String)
|
||||
data class PunchInfo(
|
||||
val id: String?,
|
||||
val title: String,
|
||||
val status: String,
|
||||
val meta: String,
|
||||
val needsGps: Boolean
|
||||
)
|
||||
data class PunchResult(val ok: Boolean, val msg: String)
|
||||
|
||||
class PunchApi {
|
||||
|
||||
companion object {
|
||||
private const val APP_DOMAIN = "wx703c2206450720dd.g8n.cn"
|
||||
private const val BASE_URL = "https://$APP_DOMAIN"
|
||||
private const val LOGIN_DOMAIN = "login.b8n.cn"
|
||||
private const val LOGIN_URL = "https://$LOGIN_DOMAIN"
|
||||
private const val SCHOOL_ID = 45
|
||||
private const val UA =
|
||||
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/145.0.0.0 Mobile Safari/537.36"
|
||||
}
|
||||
|
||||
private val cookieStore = ConcurrentHashMap<String, MutableList<Cookie>>()
|
||||
|
||||
private val cookieJar = object : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val host = url.host
|
||||
cookieStore.getOrPut(host) { mutableListOf() }.apply {
|
||||
for (c in cookies) {
|
||||
removeAll { it.name == c.name }
|
||||
add(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return cookieStore[url.host] ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.build()
|
||||
|
||||
private val clientNoRedirect = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
|
||||
var currentUser: String = ""
|
||||
private set
|
||||
|
||||
private fun getUid(): String? {
|
||||
val allCookies = cookieStore[APP_DOMAIN] ?: return null
|
||||
for (c in allCookies) {
|
||||
val m = Regex("(\\d+)%7C").find(c.value)
|
||||
if (m != null) return m.groupValues[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
data class WeixinQrResult(val ok: Boolean, val qrUrl: String = "", val msg: String = "")
|
||||
data class CheckLoginResult(val ok: Boolean, val redirectUrl: String = "")
|
||||
|
||||
fun initLoginSession() {
|
||||
val req = Request.Builder()
|
||||
.url("$BASE_URL/student/login")
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "text/html,*/*")
|
||||
.build()
|
||||
client.newCall(req).execute().close()
|
||||
android.util.Log.d("PunchApi", "login session init done")
|
||||
}
|
||||
|
||||
fun getCaptchaBytes(): ByteArray {
|
||||
initLoginSession()
|
||||
val req = Request.Builder()
|
||||
.url("$LOGIN_URL/captcha/default?t=${System.currentTimeMillis()}")
|
||||
.header("User-Agent", UA)
|
||||
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
|
||||
.build()
|
||||
client.newCall(req).execute().use { return it.body?.bytes() ?: ByteArray(0) }
|
||||
}
|
||||
|
||||
fun refreshCaptchaOnly(): ByteArray {
|
||||
val req = Request.Builder()
|
||||
.url("$LOGIN_URL/captcha/default?t=${System.currentTimeMillis()}")
|
||||
.header("User-Agent", UA)
|
||||
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
|
||||
.build()
|
||||
client.newCall(req).execute().use { return it.body?.bytes() ?: ByteArray(0) }
|
||||
}
|
||||
|
||||
fun loginAndGetQr(name: String, no: String, checkcode: String): WeixinQrResult {
|
||||
val formBody = FormBody.Builder()
|
||||
.add("name", name)
|
||||
.add("no", no)
|
||||
.add("checkcode", checkcode)
|
||||
.build()
|
||||
|
||||
val req = Request.Builder()
|
||||
.url("$LOGIN_URL/fields/login/student/$SCHOOL_ID")
|
||||
.header("User-Agent", UA)
|
||||
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
|
||||
.header("Origin", LOGIN_URL)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "zh-CN,zh;q=0.9")
|
||||
.header("Upgrade-Insecure-Requests", "1")
|
||||
.post(formBody)
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
|
||||
android.util.Log.d("PunchApi", "login POST → HTTP ${resp.code}, title match=${("<title>stop</title>" in body)}")
|
||||
|
||||
if ("验证码" in body && ("错误" in body || "不正确" in body))
|
||||
return WeixinQrResult(false, msg = "验证码错误,请刷新重试")
|
||||
if ("不匹配" in body)
|
||||
return WeixinQrResult(false, msg = "姓名与学号不匹配")
|
||||
if ("不存在" in body)
|
||||
return WeixinQrResult(false, msg = "学号不存在")
|
||||
|
||||
if ("<title>stop</title>" in body || "选择其他登录方式" in body || "/qr/weixin/" in body) {
|
||||
android.util.Log.d("PunchApi", "got stop page, fetching weixin QR...")
|
||||
return fetchWeixinQr()
|
||||
}
|
||||
|
||||
val doc = Jsoup.parse(body)
|
||||
val err = doc.selectFirst(".alert-danger, .text-danger, .error, .invalid-feedback")
|
||||
return WeixinQrResult(false, msg = err?.text()?.ifEmpty { "登录失败" } ?: "登录失败")
|
||||
}
|
||||
|
||||
private fun fetchWeixinQr(): WeixinQrResult {
|
||||
val qrReq = Request.Builder()
|
||||
.url("$LOGIN_URL/qr/weixin/student/$SCHOOL_ID")
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "text/html,*/*")
|
||||
.header("Referer", "$LOGIN_URL/fields/login/student/$SCHOOL_ID")
|
||||
.build()
|
||||
val resp = client.newCall(qrReq).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
|
||||
val m = Regex("""var\s+qrurl\s*=\s*"([^"]+)"""").find(body)
|
||||
if (m != null) {
|
||||
val url = m.groupValues[1]
|
||||
android.util.Log.d("PunchApi", "got qrurl: $url")
|
||||
return WeixinQrResult(true, url)
|
||||
}
|
||||
|
||||
android.util.Log.d("PunchApi", "qr page fail: ${body.take(400)}")
|
||||
return WeixinQrResult(false, msg = "无法获取微信登录链接")
|
||||
}
|
||||
|
||||
fun checkWeixinLogin(): CheckLoginResult {
|
||||
val req = Request.Builder()
|
||||
.url("$LOGIN_URL/qr/weixin/student/$SCHOOL_ID?op=checklogin")
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/json, */*")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Referer", "$LOGIN_URL/qr/weixin/student/$SCHOOL_ID")
|
||||
.build()
|
||||
val resp = client.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
|
||||
android.util.Log.d("PunchApi", "checklogin: $body")
|
||||
|
||||
if ("\"status\":true" in body || "\"status\": true" in body) {
|
||||
val um = Regex("""\"url\"\s*:\s*\"([^\"]+)\"""").find(body)
|
||||
val redirectUrl = um?.groupValues?.get(1) ?: ""
|
||||
return CheckLoginResult(true, redirectUrl)
|
||||
}
|
||||
return CheckLoginResult(false)
|
||||
}
|
||||
|
||||
fun completeWeixinLogin(redirectUrl: String): LoginResult {
|
||||
val cleaned = redirectUrl.replace("\\/", "/")
|
||||
val fullUrl = if (cleaned.startsWith("http")) cleaned
|
||||
else "$LOGIN_URL$cleaned"
|
||||
android.util.Log.d("PunchApi", "completing login, redirect: $fullUrl")
|
||||
|
||||
val req = Request.Builder()
|
||||
.url(fullUrl)
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "text/html,*/*")
|
||||
.build()
|
||||
val resp = client.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
android.util.Log.d("PunchApi", "complete login final url: ${resp.request.url}")
|
||||
|
||||
val m = Regex("uname['\"]?\\s*[:=]\\s*['\"]([^'\"]+)").find(body)
|
||||
currentUser = m?.groupValues?.get(1) ?: ""
|
||||
|
||||
val appCookies = cookieStore[APP_DOMAIN]
|
||||
if (appCookies?.any { it.name == "s" } == true) {
|
||||
return LoginResult(true, "登录成功", currentUser.ifEmpty { "已登录" })
|
||||
}
|
||||
|
||||
if ("课程" in body || "考勤" in body || "uname" in body) {
|
||||
return LoginResult(true, "登录成功", currentUser.ifEmpty { "已登录" })
|
||||
}
|
||||
|
||||
return LoginResult(false, "登录跳转失败")
|
||||
}
|
||||
|
||||
fun cookieLogin(s: String, remember: String = ""): LoginResult {
|
||||
val sCookie = Cookie.Builder()
|
||||
.domain(APP_DOMAIN).name("s").value(s).path("/").build()
|
||||
cookieStore.getOrPut(APP_DOMAIN) { mutableListOf() }.apply {
|
||||
removeAll { it.name == "s" }
|
||||
add(sCookie)
|
||||
}
|
||||
|
||||
if (remember.isNotEmpty()) {
|
||||
val rCookie = Cookie.Builder()
|
||||
.domain(APP_DOMAIN)
|
||||
.name("remember_student_59ba36addc2b2f9401580f014c7f58ea4e30989d")
|
||||
.value(remember).path("/").build()
|
||||
cookieStore.getOrPut(APP_DOMAIN) { mutableListOf() }.apply {
|
||||
removeAll { it.name.startsWith("remember_student") }
|
||||
add(rCookie)
|
||||
}
|
||||
}
|
||||
|
||||
val req = Request.Builder()
|
||||
.url("$BASE_URL/student")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
val resp = client.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
|
||||
if ("uname" in body || "课程" in body || "考勤" in body) {
|
||||
val m = Regex("uname['\"]?\\s*[:=]\\s*['\"]([^'\"]+)").find(body)
|
||||
currentUser = m?.groupValues?.get(1) ?: "已登录"
|
||||
return LoginResult(true, "Cookie 登录成功", currentUser)
|
||||
}
|
||||
return LoginResult(false, "Cookie 无效或已过期")
|
||||
}
|
||||
|
||||
fun getCourses(): List<CourseInfo> {
|
||||
val req = Request.Builder()
|
||||
.url("$BASE_URL/student")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
val resp = client.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
|
||||
val doc = Jsoup.parse(body)
|
||||
val courses = mutableListOf<CourseInfo>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
for (a in doc.select("a[href~=/student/course/\\d+]")) {
|
||||
val m = Regex("/student/course/(\\d+)").find(a.attr("href")) ?: continue
|
||||
val cid = m.groupValues[1]
|
||||
if (cid in seen) continue
|
||||
seen.add(cid)
|
||||
val title = a.text().trim().ifEmpty { "课程#$cid" }
|
||||
courses.add(CourseInfo(cid, title))
|
||||
}
|
||||
return courses
|
||||
}
|
||||
|
||||
fun getPunchs(courseId: String): List<PunchInfo> {
|
||||
val req = Request.Builder()
|
||||
.url("$BASE_URL/student/course/$courseId/punchs?op=ing")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
val resp = client.newCall(req).execute()
|
||||
val html = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
|
||||
val doc = Jsoup.parse(html)
|
||||
val punchs = mutableListOf<PunchInfo>()
|
||||
|
||||
for (card in doc.select(".punch-card")) {
|
||||
val titleEl = card.selectFirst(".font-weight-bold")
|
||||
val statusEl = card.selectFirst(".punch-status")
|
||||
val title = titleEl?.text()?.trim() ?: "未知"
|
||||
val status = statusEl?.text()?.trim() ?: "未知"
|
||||
|
||||
var punchId: String? = null
|
||||
|
||||
for (a in card.select("a[href]")) {
|
||||
val m = Regex("/punchs?/(?:course/)?(?:\\d+/)?(\\d{5,})").find(a.attr("href"))
|
||||
if (m != null) {
|
||||
punchId = m.groupValues[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (punchId == null) {
|
||||
for (tag in card.allElements) {
|
||||
for ((_, v) in tag.attributes()) {
|
||||
val m = Regex("(\\d{7,})").find(v)
|
||||
if (m != null) {
|
||||
punchId = m.groupValues[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (punchId != null) break
|
||||
}
|
||||
}
|
||||
|
||||
val metaEl = card.selectFirst(".punch-meta")
|
||||
val meta = metaEl?.text()?.trim() ?: ""
|
||||
val needsGps = "GPS" in meta || "gps" in meta.lowercase()
|
||||
|
||||
punchs.add(PunchInfo(punchId, title, status, meta, needsGps))
|
||||
}
|
||||
return punchs
|
||||
}
|
||||
|
||||
fun doPunch(courseId: String, punchId: String, lat: Double, lng: Double, acc: Int = 20): PunchResult {
|
||||
val uid = getUid()
|
||||
var url = "$BASE_URL/student/punchs/course/$courseId/$punchId"
|
||||
if (uid != null) url += "?sid=$uid"
|
||||
|
||||
val formBody = FormBody.Builder()
|
||||
.add("lat", lat.toString())
|
||||
.add("lng", lng.toString())
|
||||
.add("acc", acc.toString())
|
||||
.add("res", "")
|
||||
.build()
|
||||
|
||||
val req = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", UA)
|
||||
.header("Origin", BASE_URL)
|
||||
.header("Referer", url)
|
||||
.post(formBody)
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(req).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
|
||||
return when {
|
||||
"已签到" in body || "签到过" in body || "已签" in body ->
|
||||
PunchResult(true, "签到成功!")
|
||||
"成功" in body || "success" in body.lowercase() ->
|
||||
PunchResult(true, "签到成功!")
|
||||
"未开始" in body ->
|
||||
PunchResult(false, "签到尚未开始")
|
||||
"已结束" in body ->
|
||||
PunchResult(false, "签到已结束")
|
||||
else ->
|
||||
PunchResult(true, "已提交,请确认状态")
|
||||
}
|
||||
}
|
||||
|
||||
fun isLoggedIn(): Boolean {
|
||||
return cookieStore[APP_DOMAIN]?.any { it.name == "s" } == true
|
||||
}
|
||||
|
||||
fun getSavedCookieS(): String {
|
||||
return cookieStore[APP_DOMAIN]?.find { it.name == "s" }?.value ?: ""
|
||||
}
|
||||
|
||||
fun getSavedCookieRemember(): String {
|
||||
return cookieStore[APP_DOMAIN]
|
||||
?.find { it.name.startsWith("remember_student") }?.value ?: ""
|
||||
}
|
||||
|
||||
fun clearCookies() {
|
||||
cookieStore.clear()
|
||||
currentUser = ""
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/drawable/bg_captcha.xml
Normal file
5
app/src/main/res/drawable/bg_captcha.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<stroke android:width="1dp" android:color="#e0e0e0" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
8
app/src/main/res/drawable/bg_gps_box.xml
Normal file
8
app/src/main/res/drawable/bg_gps_box.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#f6f8fb"
|
||||
android:endColor="#e8edff"
|
||||
android:angle="135" />
|
||||
<corners android:radius="14dp" />
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_header.xml
Normal file
7
app/src/main/res/drawable/bg_header.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#3a5ef7"
|
||||
android:endColor="#6b8cff"
|
||||
android:angle="135" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_pill_danger.xml
Normal file
5
app/src/main/res/drawable/bg_pill_danger.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#1eff4d4f" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_pill_muted.xml
Normal file
5
app/src/main/res/drawable/bg_pill_muted.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#1e8a94a6" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_pill_primary.xml
Normal file
5
app/src/main/res/drawable/bg_pill_primary.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#1e4f6ef7" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_pill_success.xml
Normal file
5
app/src/main/res/drawable/bg_pill_success.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#1e21b26f" />
|
||||
<corners android:radius="999dp" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_user_bar.xml
Normal file
6
app/src/main/res/drawable/bg_user_bar.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#33ffffff" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
22
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
22
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,30 C40.745,30 30,40.745 30,54 C30,67.255 40.745,78 54,78 C67.255,78 78,67.255 78,54 C78,40.745 67.255,30 54,30ZM54,34 C65.046,34 74,42.954 74,54 C74,65.046 65.046,74 54,74 C42.954,74 34,65.046 34,54 C34,42.954 42.954,34 54,34ZM54,44 C48.477,44 44,48.477 44,54 C44,59.523 48.477,64 54,64 C59.523,64 64,59.523 64,54 C64,48.477 59.523,44 54,44ZM54,48 C57.314,48 60,50.686 60,54 C60,57.314 57.314,60 54,60 C50.686,60 48,57.314 48,54 C48,50.686 50.686,48 54,48Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,24 L55,24 L55,32 L53,32Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,76 L55,76 L55,84 L53,84Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M24,53 L32,53 L32,55 L24,55Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M76,53 L84,53 L84,55 L76,55Z" />
|
||||
</vector>
|
||||
729
app/src/main/res/layout/activity_main.xml
Normal file
729
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,729 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="#f0f2f5">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_header"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="GPS 签到助手"
|
||||
android:textColor="#ffffff"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="自定义位置 · 一键打卡"
|
||||
android:textColor="#ccddff"
|
||||
android:textSize="13sp"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<!-- User bar -->
|
||||
<LinearLayout
|
||||
android:id="@+id/headerUserBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/bg_user_bar"
|
||||
android:padding="10dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvUser"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textColor="#ffffff"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnLogout"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="退出"
|
||||
android:textColor="#ffffff"
|
||||
android:textSize="12sp"
|
||||
android:minWidth="0dp"
|
||||
android:paddingHorizontal="12dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ===== Login View ===== -->
|
||||
<ScrollView
|
||||
android:id="@+id/loginView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<!-- Login Form Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="学生身份验证"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="姓名"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="学号"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etNo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="验证码(点击图片刷新)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="4位数字"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginEnd="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etCode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLength="6" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/captchaImg"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="48dp"
|
||||
android:scaleType="fitXY"
|
||||
android:background="@drawable/bg_captcha"
|
||||
android:contentDescription="验证码" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLogin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:text="登 录"
|
||||
android:textSize="16sp"
|
||||
app:cornerRadius="12dp" />
|
||||
|
||||
<!-- removed, moved to guideView -->
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Divider -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginVertical="8dp">
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1"
|
||||
android:background="#e0e0e0" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=" 或 "
|
||||
android:textColor="#999"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1"
|
||||
android:background="#e0e0e0" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Cookie Login Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Cookie 登录"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="从微信浏览器抓包获取 Cookie"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#999"
|
||||
android:layout_marginBottom="14dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="s (session cookie)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etCookieS"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="remember_student(可选)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etCookieRemember"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCookieLogin"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:text="Cookie 登录"
|
||||
android:textSize="15sp"
|
||||
app:cornerRadius="12dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- ===== WeChat Guide View ===== -->
|
||||
<ScrollView
|
||||
android:id="@+id/guideView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<!-- Success Icon -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="微信登录"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<!-- Steps Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="操作步骤"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="① 点击下方按钮复制登录链接"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:drawablePadding="8dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="② 打开微信,发送给「文件传输助手」"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="③ 在微信中点击该链接"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="④ 返回本 App,自动登录完成"
|
||||
android:textSize="15sp"
|
||||
android:textColor="@color/primary"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Link Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="登录链接"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#666"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvWeixinLink"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_gps_box"
|
||||
android:padding="14dp"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@color/primary"
|
||||
android:fontFamily="monospace"
|
||||
android:textIsSelectable="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCopyLink"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="54dp"
|
||||
android:text="复制链接到剪贴板"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
app:cornerRadius="12dp"
|
||||
android:layout_marginTop="14dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Polling Status -->
|
||||
<TextView
|
||||
android:id="@+id/tvPollingStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/primary"
|
||||
android:textStyle="bold"
|
||||
android:padding="16dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Back button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnBackToLogin"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="返回重新登录"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- ===== Main Content ===== -->
|
||||
<FrameLayout
|
||||
android:id="@+id/mainContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Punch View -->
|
||||
<ScrollView
|
||||
android:id="@+id/punchView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="visible">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- GPS Quick Box -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/bg_gps_box"
|
||||
android:padding="16dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📍 当前定位"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="纬度 (lat)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginEnd="8dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal|numberSigned"
|
||||
android:hint="输入纬度" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="经度 (lng)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLng"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal|numberSigned"
|
||||
android:hint="输入经度" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipGroupPresets"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
app:singleSelection="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Punch list header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="签到列表"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnRefresh"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:text="刷新"
|
||||
android:textSize="13sp"
|
||||
app:cornerRadius="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/punchListContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="点击「刷新」获取签到列表"
|
||||
android:textColor="#8a94a6"
|
||||
android:textSize="14sp"
|
||||
android:gravity="center"
|
||||
android:padding="48dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- GPS View -->
|
||||
<ScrollView
|
||||
android:id="@+id/gpsView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="添加常用位置"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="14dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="位置名称"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPresetName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="14dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="纬度"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginEnd="8dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPresetLat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal|numberSigned" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="经度"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPresetLng"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal|numberSigned" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnAddPreset"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="添加到常用位置"
|
||||
app:cornerRadius="12dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Saved presets list -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="已保存的位置"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/presetListContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<!-- Guide -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp"
|
||||
android:layout_marginTop="14dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="如何获取坐标?"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="10dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1. 打开手机地图 App(高德/百度/腾讯)\n2. 长按目标位置,弹出坐标信息\n3. 复制经纬度填入上方\n\n注意:使用 GCJ-02 坐标系(高德/腾讯地图默认即是)"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#666"
|
||||
android:lineSpacingExtra="4dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- History View -->
|
||||
<ScrollView
|
||||
android:id="@+id/historyView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/historyListContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp" />
|
||||
</ScrollView>
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/bottomNav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/white"
|
||||
app:menu="@menu/bottom_nav"
|
||||
app:labelVisibilityMode="labeled"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
47
app/src/main/res/layout/item_history.xml
Normal file
47
app/src/main/res/layout/item_history.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryResult"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="3dp"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#8a94a6"
|
||||
android:layout_marginTop="6dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
49
app/src/main/res/layout/item_preset.xml
Normal file
49
app/src/main/res/layout/item_preset.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="14dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPresetName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPresetCoord"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#8a94a6"
|
||||
android:layout_marginTop="2dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDeletePreset"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="删除"
|
||||
android:textColor="#ff4d4f"
|
||||
android:textSize="12sp"
|
||||
android:minWidth="0dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
52
app/src/main/res/layout/item_punch.xml
Normal file
52
app/src/main/res/layout/item_punch.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="4dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPunchTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1a1a2e" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPunchMeta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#8a94a6"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPunchStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
15
app/src/main/res/menu/bottom_nav.xml
Normal file
15
app/src/main/res/menu/bottom_nav.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/nav_punch"
|
||||
android:icon="@android:drawable/ic_menu_mylocation"
|
||||
android:title="签到" />
|
||||
<item
|
||||
android:id="@+id/nav_gps"
|
||||
android:icon="@android:drawable/ic_menu_mapmode"
|
||||
android:title="GPS设置" />
|
||||
<item
|
||||
android:id="@+id/nav_history"
|
||||
android:icon="@android:drawable/ic_menu_recent_history"
|
||||
android:title="历史" />
|
||||
</menu>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 B |
13
app/src/main/res/values/colors.xml
Normal file
13
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#4f6ef7</color>
|
||||
<color name="primary_dark">#3a5ef7</color>
|
||||
<color name="primary_light">#e8edff</color>
|
||||
<color name="success">#21b26f</color>
|
||||
<color name="danger">#ff4d4f</color>
|
||||
<color name="warn">#ff9f1a</color>
|
||||
<color name="muted">#8a94a6</color>
|
||||
<color name="bg">#f0f2f5</color>
|
||||
<color name="white">#ffffff</color>
|
||||
<color name="text_primary">#1a1a2e</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">GPS签到助手</string>
|
||||
</resources>
|
||||
10
app/src/main/res/values/themes.xml
Normal file
10
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.GPSPunch" parent="Theme.Material3.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<item name="android:statusBarColor">@color/primary_dark</item>
|
||||
<item name="android:windowBackground">@color/bg</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user