This commit is contained in:
wangziqi
2026-01-09 08:57:07 +08:00
parent 915e5d6a03
commit abe03cbfef
60 changed files with 556 additions and 3474 deletions

16
android/.gitignore vendored
View File

@@ -1,16 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
*.hprof

View File

@@ -1,303 +0,0 @@
# Android 客户端开发完成总结
## 项目概况
已为车管家 4S店车辆维保管理系统创建了完整的 Android 客户端应用。
**项目位置**: `car-maintenance-system/android/`
## 技术架构
### 核心技术栈
- **语言**: Kotlin
- **UI框架**: Jetpack Compose + Material 3
- **架构模式**: MVVM + Clean Architecture
- **依赖注入**: Hilt
- **网络请求**: Retrofit + OkHttp
- **异步处理**: Coroutines + Flow
- **本地存储**: DataStore Preferences
- **导航**: Navigation Compose
### 依赖版本
```kotlin
- Kotlin: 1.9.20
- Compose BOM: 2023.10.01
- Hilt: 2.48
- Retrofit: 2.9.0
- OkHttp: 4.12.0
- Target SDK: 34
- Min SDK: 26
```
## 已实现功能
### ✅ 基础架构
1. **项目配置**
- Gradle 构建配置
- Hilt 依赖注入设置
- ProGuard 混淆规则
- 网络权限配置
2. **数据层**
- 数据模型User, Vehicle, ServiceOrder, Appointment等
- Repository 模式实现
- API 接口定义
- Token 管理(自动添加到请求头)
3. **网络层**
- Retrofit 配置
- OkHttp 拦截器(日志、认证)
- API 响应统一封装
- 错误处理
### ✅ 用户认证
- 登录界面(用户名/密码/角色选择)
- Token 自动存储和管理
- 自动登录检测
- 退出登录功能
- 角色权限路由(管理员/工作人员/客户)
### ✅ 客户模块
1. **客户仪表板**
- 用户信息展示
- 快捷功能入口
- 车辆列表展示
- 车辆卡片(显示车牌、品牌、里程、保养时间)
2. **我的车辆**
- 车辆列表展示
- 加载状态
- 空状态处理
3. **维保记录**
- 工单列表展示
- 状态标签显示
- 费用信息展示
4. **我的预约**
- 预约列表展示
- 状态标签(待确认/已确认/已完成/已取消)
- 悬浮新建按钮(预留)
### ✅ 管理员模块
- 管理员仪表板
- 系统概览统计
- 管理功能菜单(用户/车辆/工单/配件/预约)
### ✅ UI/UX 设计
1. **主题系统**
- Material 3 设计语言
- 自定义颜色方案
- 深色/浅色主题支持
2. **组件设计**
- 统一的卡片样式
- 状态徽章组件
- 加载指示器
- 空状态提示
3. **导航系统**
- 导航图配置
- 角色路由分发
- 自动登录检测
## 项目文件清单
### 核心代码文件32个
```
数据层 (11个)
├── ApiResponse.kt # API响应封装
├── ApiService.kt # API接口定义
├── AuthModels.kt # 认证相关模型
├── User.kt # 用户模型
├── Vehicle.kt # 车辆模型
├── ServiceOrder.kt # 工单模型
├── Appointment.kt # 预约模型
├── TokenManager.kt # Token管理
├── AuthRepository.kt # 认证仓库
├── VehicleRepository.kt # 车辆仓库
├── OrderRepository.kt # 工单仓库
└── AppointmentRepository.kt # 预约仓库
业务层 (4个)
├── NetworkModule.kt # 网络模块
├── AuthViewModel.kt # 认证视图模型
└── CustomerViewModel.kt # 客户视图模型
UI层 (17个)
├── MainActivity.kt # 主Activity
├── CarMaintenanceApplication.kt # Application类
├── Screen.kt # 路由定义
├── Navigation.kt # 导航配置
├── Theme.kt # 主题配置
├── Type.kt # 字体样式
├── Color.kt # 颜色定义
├── LoginScreen.kt # 登录页面
├── CustomerDashboardScreen.kt # 客户仪表板
├── CustomerVehiclesScreen.kt # 我的车辆
├── CustomerOrdersScreen.kt # 维保记录
├── CustomerAppointmentsScreen.kt # 我的预约
└── AdminDashboardScreen.kt # 管理员仪表板
```
### 配置文件10个
```
├── settings.gradle.kts # Gradle设置
├── build.gradle.kts # 项目构建配置
├── gradle.properties # Gradle属性
├── .gitignore # Git忽略文件
├── app/build.gradle.kts # 应用构建配置
├── app/proguard-rules.pro # 混淆规则
├── app/src/main/AndroidManifest.xml # 清单文件
├── app/src/main/res/values/strings.xml
├── app/src/main/res/values/themes.xml
└── app/src/main/res/xml/*.xml # 备份和提取规则
```
### 文档文件2个
```
├── README.md # 项目说明
└── QUICKSTART.md # 快速开始指南
```
## 待完善功能
### 📋 高优先级
1. **在线预约功能**
- 预约表单页面
- 车辆选择器
- 服务类型选择
- 日期时间选择器
2. **详情页面**
- 车辆详情查看
- 工单详情查看
- 预约详情查看
3. **管理员完整功能**
- 用户管理CRUD
- 车辆管理CRUD
- 工单管理CRUD
- 配件管理CRUD
- 预约管理(确认/取消)
### 📋 中优先级
1. **工作人员模块**
- 我的工单列表
- 工单处理流程
- 预约确认功能
2. **数据刷新**
- 下拉刷新
- 自动刷新机制
- 状态同步
3. **表单功能**
- 车辆添加
- 信息编辑
- 表单验证
### 📋 低优先级
1. **增强功能**
- 搜索过滤
- 排序功能
- 数据导出
2. **用户体验**
- 加载动画优化
- 错误提示优化
- 空状态优化
3. **高级功能**
- 推送通知
- 离线缓存
- 图片上传
- 电子签名
## 快速开始
### 环境要求
- Android Studio Hedgehog (2023.1.1) 或更高版本
- JDK 17
- Android SDK 34
- 后端服务运行在 http://localhost:8080
### 运行步骤
1. 打开 Android Studio
2. 打开 `android` 目录
3. 等待 Gradle 同步
4. 配置 API 地址(如果需要)
5. 运行应用
### 测试账号
- 管理员: admin / 123456
- 工作人员: staff001 / 123456
- 客户: customer001 / 123456
## 架构亮点
1. **MVVM 架构**
- 清晰的职责分离
- 状态管理使用 StateFlow
- 数据自动观察和更新
2. **依赖注入**
- 使用 Hilt 管理依赖
- 模块化配置
- 编译时依赖检查
3. **网络层**
- 统一的 API 响应处理
- 自动 Token 管理
- 请求/响应日志记录
4. **导航系统**
- 类型安全的导航
- 角色路由分发
- 自动登录检测
5. **现代 UI**
- Jetpack Compose 声明式UI
- Material 3 设计规范
- 自适应布局
## 后续开发建议
1. **功能完善**
- 优先实现核心业务流程
- 补充表单和详情页面
- 添加数据验证
2. **性能优化**
- 添加图片缓存
- 优化列表滚动
- 减少内存占用
3. **用户体验**
- 添加骨架屏
- 优化加载状态
- 完善错误处理
4. **测试**
- 添加单元测试
- 添加 UI 测试
- 集成测试
## 技术债务
1. 需要添加完整的错误处理机制
2. 需要实现数据缓存策略
3. 需要添加更多的单元测试
4. 需要优化网络请求的重试逻辑
5. 需要实现更细粒度的权限控制
## 总结
Android 客户端的基础架构已完成,实现了:
- ✅ 完整的项目结构
- ✅ 网络请求层
- ✅ 用户认证系统
- ✅ 客户核心功能
- ✅ 管理员基础功能
应用可以正常编译运行,并且与后端 API 对接正常。后续可以根据业务需求逐步添加更多功能。

View File

@@ -1,222 +1,19 @@
# 车管家 Android 客户端 - 快速开始指南
# Android (Java) Quickstart
## 项目概述
## Requirements
- Android Studio Hedgehog (2023.1.1) or newer
- JDK 17
- Android SDK 34
这是一个现代化的 Android 应用,为车管家 4S店车辆维保管理系统提供移动端支持。
## Run
1. Open Android Studio
2. Open the `android` folder
3. Let Gradle sync
4. Update API base URL if needed:
- `android/app/build.gradle` -> `API_BASE_URL`
5. Run `LoginActivity`
## 开发前准备
### 1. 安装 Android Studio
下载并安装最新版本的 Android Studio:
https://developer.android.com/studio
### 2. 配置 JDK
确保安装了 JDK 17 或更高版本:
```bash
java -version # 应该显示 17.x.x 或更高
```
### 3. 启动后端服务
在运行 Android 应用之前,确保后端服务正在运行:
```bash
# 进入后端项目目录
cd car-maintenance-system/backend
# 使用 Maven 启动服务
mvn spring-boot:run
# 或者在 IDEA 中运行 SpringBootAppApplication.java
```
后端默认运行在 `http://localhost:8080`
### 4. 配置 API 地址
根据你的测试环境,修改 `app/build.gradle.kts`
**使用 Android 模拟器:**
```kotlin
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:8080/api/\"")
```
**使用真机:**
```kotlin
buildConfigField("String", "API_BASE_URL", "\"http://YOUR_PC_IP:8080/api/\"")
// 例如: "http://192.168.1.100:8080/api/"
```
## 运行应用
### 方式一:使用 Android Studio
1. 打开 Android Studio
2. 选择 **File > Open**
3. 选择 `car-maintenance-system/android` 目录
4. 等待 Gradle 同步完成
5. 点击工具栏的 Run 按钮 (绿色三角形)
### 方式二:使用命令行
```bash
cd android
# 查看连接的设备
adb devices
# 安装并运行
./gradlew installDebug
```
## 测试账号
| 角色 | 用户名 | 密码 | 功能 |
|------|--------|------|------|
| 管理员 | admin | 123456 | 系统管理、数据统计 |
| 工作人员 | staff001 | 123456 | 工单处理、预约确认 |
| 客户 | customer001 | 123456 | 查看车辆、预约服务 |
## 项目结构说明
```
android/
├── app/ # 应用模块
│ ├── src/main/
│ │ ├── java/com/carmaintenance/
│ │ │ ├── data/ # 数据层
│ │ │ │ ├── local/ # 本地存储
│ │ │ │ ├── model/ # 数据模型
│ │ │ │ ├── remote/ # API接口
│ │ │ │ ├── repository/ # 数据仓库
│ │ │ │ └── manager/ # Token管理
│ │ │ ├── di/ # 依赖注入
│ │ │ ├── ui/ # UI层
│ │ │ │ ├── navigation/ # 导航
│ │ │ │ ├── screen/ # 页面
│ │ │ │ ├── theme/ # 主题
│ │ │ │ └── viewmodel/ # 视图模型
│ │ │ ├── CarMaintenanceApplication.kt
│ │ │ └── MainActivity.kt
│ │ └── res/ # 资源文件
│ ├── build.gradle.kts # 应用级构建配置
│ └── proguard-rules.pro # 混淆规则
├── build.gradle.kts # 项目级构建配置
├── settings.gradle.kts # Gradle设置
└── gradle.properties # Gradle属性
```
## 常见问题
### 1. Gradle 同步失败
**问题**: Gradle 同步时出现错误
**解决方案**:
```bash
# 清理项目
./gradlew clean
# 重新构建
./gradlew build
# 如果还是失败,删除 .gradle 目录后重试
rm -rf .gradle
```
### 2. 网络请求失败
**问题**: 应用无法连接到服务器
**解决方案**:
- 确认后端服务正在运行
- 检查 API_BASE_URL 配置
- 确保设备和电脑在同一网络(真机测试)
- 检查防火墙设置
### 3. 登录失败
**问题**: 登录时提示错误
**解决方案**:
- 确认后端数据库中有测试用户
- 检查用户名和密码是否正确
- 查看后端日志确认请求是否到达
### 4. 模拟器无法访问宿主机
**问题**: 模拟器中的 App 无法访问电脑上的后端服务
**解决方案**:
使用 `10.0.2.2` 代替 `localhost`:
```kotlin
// 错误
buildConfigField("String", "API_BASE_URL", "\"http://localhost:8080/api/\"")
// 正确(模拟器)
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:8080/api/\"")
```
### 5. 数据显示为空
**问题**: 登录后车辆、工单等数据显示为空
**解决方案**:
- 确认后端数据库有相关数据
- 使用 Admin 账号登录后台添加测试数据
- 检查客户ID是否正确关联
## 调试技巧
### 查看 API 请求
`NetworkModule.kt` 中,日志级别已设置为 `BODY`
```kotlin
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
}
```
在 Logcat 中过滤 `OkHttp` 即可看到所有网络请求。
### 查看 Token 存储
在 Logcat 中过滤 `TokenManager` 可以看到 Token 相关的日志。
## 下一步开发
### 功能优先级
1. **高优先级**
- 在线预约表单
- 车辆详情查看
- 工单详情查看
2. **中优先级**
- 管理员完整功能
- 工作人员功能
- 推送通知
3. **低优先级**
- 数据刷新机制
- 离线缓存
- 图片上传
### 扩展功能建议
- [ ] 车辆照片上传
- [ ] 电子签名
- [ ] 扫码查看车辆
- [ ] 消息推送Firebase
- [ ] 支付集成
- [ ] 数据导出
- [ ] 多语言支持
## 技术支持
如有问题,请查看:
- [Jetpack Compose 官方文档](https://developer.android.com/jetpack/compose)
- [Hilt 官方文档](https://dagger.dev/hilt/)
- [Retrofit 官方文档](https://square.github.io/retrofit/)
## Test Accounts
- admin / 123456
- staff001 / 123456
- customer001 / 123456

View File

@@ -1,182 +0,0 @@
# 车管家 4S店车辆维保管理系统 - Android 客户端
基于 **Kotlin + Jetpack Compose** 开发的现代化 Android 应用。
## 技术栈
- **UI 框架**: Jetpack Compose (Material 3)
- **架构**: MVVM + Clean Architecture
- **依赖注入**: Hilt
- **网络请求**: Retrofit + OkHttp
- **异步处理**: Coroutines + Flow
- **本地存储**: DataStore (用于存储Token和用户信息)
- **导航**: Navigation Compose
## 项目结构
```
app/
├── data/ # 数据层
│ ├── local/ # 本地数据
│ ├── model/ # 数据模型
│ ├── remote/ # 远程数据源
│ │ ├── ApiService.kt # API接口定义
│ │ └── ApiResponse.kt # API响应封装
│ ├── repository/ # 仓库层
│ └── manager/ # 数据管理器
├── di/ # 依赖注入模块
│ └── NetworkModule.kt # 网络模块配置
├── ui/ # UI层
│ ├── navigation/ # 导航配置
│ ├── screen/ # 页面
│ │ ├── customer/ # 客户模块
│ │ └── admin/ # 管理员模块
│ ├── theme/ # 主题配置
│ └── viewmodel/ # ViewModel
└── MainActivity.kt # 主Activity
```
## 功能模块
### 已实现功能
- ✅ 用户登录/登出
- ✅ 角色权限管理(管理员、工作人员、客户)
- ✅ 客户仪表板
- 查看我的车辆
- 查看维保记录
- 查看预约记录
- ✅ 管理员仪表板(基础版)
- ✅ 数据持久化Token存储
### 待完善功能
- ⏳ 在线预约表单
- ⏳ 管理员完整功能
- ⏳ 工作人员功能
- ⏳ 推送通知
- ⏳ 车辆详情查看
- ⏳ 工单详情查看
## 开发环境要求
- Android Studio Hedgehog (2023.1.1) 或更高版本
- JDK 17
- Android SDK 34
- Gradle 8.2
## 配置说明
### API 地址配置
`app/build.gradle.kts` 中修改 API_BASE_URL
```kotlin
// 模拟器测试访问宿主机localhost
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:8080/api/\"")
// 真机测试改为实际IP
buildConfigField("String", "API_BASE_URL", "\"http://192.168.1.100:8080/api/\"")
```
### 测试账号
- **管理员**: admin / 123456
- **工作人员**: staff001 / 123456
- **客户**: customer001 / 123456
## 构建和运行
### 使用 Android Studio
1. 打开 Android Studio
2. 选择 `File > Open`,选择 `android` 目录
3. 等待 Gradle 同步完成
4. 连接 Android 模拟器或真机
5. 点击 Run 按钮(或按 Shift+F10
### 使用命令行
```bash
# 进入项目目录
cd android
# 构建 Debug 版本
./gradlew assembleDebug
# 安装到设备
./gradlew installDebug
# 或者直接运行
./gradlew installDebug
```
## 主要依赖
```kotlin
// Compose BOM
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
// Hilt
implementation("com.google.dagger:hilt-android:2.48")
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// OkHttp
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// DataStore
implementation("androidx.datastore:datastore-preferences:1.0.0")
```
## 开发注意事项
### 网络权限
已在 `AndroidManifest.xml` 中配置:
- `INTERNET` - 访问网络
- `ACCESS_NETWORK_STATE` - 检查网络状态
- `usesCleartextTraffic="true"` - 允许HTTP请求仅用于开发
### ProGuard 配置
已添加 Retrofit、OkHttp、Gson、Hilt 的混淆规则。
## 架构设计
### 分层架构
```
Presentation Layer (UI)
Domain Layer (ViewModel)
Data Layer (Repository)
Remote Data Source (API)
```
### 数据流
```
UI Screen
↓ (用户操作)
ViewModel
↓ (调用方法)
Repository
↓ (网络请求)
API Service
↓ (返回数据)
Repository (处理数据)
↓ (StateFlow)
ViewModel (状态更新)
↓ (collectAsState)
UI Screen (自动更新)
```
## 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 许可证
本项目采用 MIT 许可证

43
android/app/build.gradle Normal file
View File

@@ -0,0 +1,43 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.carmaintenance'
compileSdk 34
defaultConfig {
applicationId 'com.carmaintenance'
minSdk 24
targetSdk 34
versionCode 1
versionName '1.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
buildConfigField 'String', 'API_BASE_URL', '"http://10.0.2.2:8080/api/"'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
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.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}

View File

@@ -1,114 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.carmaintenance"
compileSdk = 34
defaultConfig {
applicationId = "com.carmaintenance.app"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// API基础URL配置
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:8080/api/\"")
// 模拟器使用10.0.2.2访问宿主机localhost
// 真机测试需要改为实际IP地址如: "http://192.168.1.100:8080/api/"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
// Compose BOM
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.5")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
// Hilt Dependency Injection
implementation("com.google.dagger:hilt-android:2.48")
ksp("com.google.dagger:hilt-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Local Storage - DataStore
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Image Loading
implementation("io.coil-kt:coil-compose:2.5.0")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

View File

@@ -1,49 +1 @@
# Project-level Gradle stuff here has been deprecated, with the default one being expected.
# For custom project-level configs, place them in the appropriate sub-projects.
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Retrofit
-dontwarn okhttp3.**
-dontwarn retrofit2.**
-keep class retrofit2.** { *; }
-keepattributes Signature
-keepattributes Exceptions
# OkHttp
-dontwarn okhttp3.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Gson
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class * implements com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Keep Hilt generated classes
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper

View File

@@ -1,26 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".CarMaintenanceApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="Car Maintenance"
android:icon="@drawable/ic_launcher"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.CarMaintenance"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.CarMaintenance">
<activity android:name=".RoleHomeActivity" />
<activity
android:name=".LoginActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@@ -0,0 +1,30 @@
package com.carmaintenance;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class ApiClient {
private static ApiService service;
public static ApiService getService() {
if (service == null) {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(logging)
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(client)
.build();
service = retrofit.create(ApiService.class);
}
return service;
}
}

View File

@@ -0,0 +1,19 @@
package com.carmaintenance;
public class ApiResult<T> {
private int code;
private String message;
private T data;
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
public T getData() {
return data;
}
}

View File

@@ -0,0 +1,10 @@
package com.carmaintenance;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.POST;
public interface ApiService {
@POST("auth/login")
Call<ApiResult<LoginResponse>> login(@Body LoginRequest request);
}

View File

@@ -1,12 +0,0 @@
package com.carmaintenance
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class CarMaintenanceApplication : Application() {
override fun onCreate() {
super.onCreate()
}
}

View File

@@ -0,0 +1,104 @@
package com.carmaintenance;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.textfield.TextInputEditText;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class LoginActivity extends AppCompatActivity {
private TextInputEditText usernameInput;
private TextInputEditText passwordInput;
private ProgressBar loginProgress;
private TextView loginError;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
usernameInput = findViewById(R.id.usernameInput);
passwordInput = findViewById(R.id.passwordInput);
loginProgress = findViewById(R.id.loginProgress);
loginError = findViewById(R.id.loginError);
Button loginButton = findViewById(R.id.loginButton);
loginButton.setOnClickListener(v -> attemptLogin());
}
private void attemptLogin() {
String username = textOf(usernameInput);
String password = textOf(passwordInput);
if (TextUtils.isEmpty(username) || TextUtils.isEmpty(password)) {
showError("Username and password are required.");
return;
}
setLoading(true);
LoginRequest request = new LoginRequest(username, password);
ApiClient.getService().login(request).enqueue(new Callback<ApiResult<LoginResponse>>() {
@Override
public void onResponse(Call<ApiResult<LoginResponse>> call, Response<ApiResult<LoginResponse>> response) {
setLoading(false);
if (!response.isSuccessful() || response.body() == null) {
showError("Login failed. Please try again.");
return;
}
ApiResult<LoginResponse> result = response.body();
if (result.getCode() != 200 || result.getData() == null) {
showError(result.getMessage() != null ? result.getMessage() : "Login failed.");
return;
}
LoginResponse data = result.getData();
new TokenStore(LoginActivity.this).saveToken(data.getToken());
String role = data.getUserInfo() != null ? data.getUserInfo().getRole() : "unknown";
String displayName = data.getUserInfo() != null ? data.getUserInfo().getUsername() : username;
Intent intent = new Intent(LoginActivity.this, RoleHomeActivity.class);
intent.putExtra("role", role);
intent.putExtra("username", displayName);
startActivity(intent);
finish();
}
@Override
public void onFailure(Call<ApiResult<LoginResponse>> call, Throwable t) {
setLoading(false);
showError("Network error: " + t.getMessage());
}
});
}
private String textOf(TextInputEditText input) {
if (input.getText() == null) {
return "";
}
return input.getText().toString().trim();
}
private void setLoading(boolean loading) {
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
loginError.setVisibility(View.GONE);
}
private void showError(String message) {
loginError.setText(message);
loginError.setVisibility(View.VISIBLE);
}
}

View File

@@ -0,0 +1,11 @@
package com.carmaintenance;
public class LoginRequest {
private final String username;
private final String password;
public LoginRequest(String username, String password) {
this.username = username;
this.password = password;
}
}

View File

@@ -0,0 +1,47 @@
package com.carmaintenance;
public class LoginResponse {
private String token;
private UserInfo userInfo;
public String getToken() {
return token;
}
public UserInfo getUserInfo() {
return userInfo;
}
public static class UserInfo {
private Integer userId;
private String username;
private String realName;
private String phone;
private String email;
private String role;
public Integer getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public String getRealName() {
return realName;
}
public String getPhone() {
return phone;
}
public String getEmail() {
return email;
}
public String getRole() {
return role;
}
}
}

View File

@@ -1,29 +0,0 @@
package com.carmaintenance
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.carmaintenance.ui.navigation.CarMaintenanceNavGraph
import com.carmaintenance.ui.theme.CarMaintenanceTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CarMaintenanceTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
CarMaintenanceNavGraph()
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
package com.carmaintenance;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
public class RoleHomeActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_role_home);
TextView userInfo = findViewById(R.id.userInfo);
Button primaryAction = findViewById(R.id.primaryAction);
Button secondaryAction = findViewById(R.id.secondaryAction);
Button logoutButton = findViewById(R.id.logoutButton);
String role = getIntent().getStringExtra("role");
String username = getIntent().getStringExtra("username");
if (role == null) {
role = "unknown";
}
if (username == null) {
username = "user";
}
userInfo.setText("Welcome " + username + " (" + role + ")");
if ("admin".equalsIgnoreCase(role)) {
primaryAction.setText("Manage Users");
secondaryAction.setText("Manage Orders");
} else if ("staff".equalsIgnoreCase(role)) {
primaryAction.setText("Search Vehicles");
secondaryAction.setText("Parts Lookup");
} else {
primaryAction.setText("My Vehicles");
secondaryAction.setText("My Appointments");
}
logoutButton.setOnClickListener(v -> {
new TokenStore(RoleHomeActivity.this).clear();
Intent intent = new Intent(RoleHomeActivity.this, LoginActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
});
}
}

View File

@@ -0,0 +1,27 @@
package com.carmaintenance;
import android.content.Context;
import android.content.SharedPreferences;
public class TokenStore {
private static final String PREFS_NAME = "auth_prefs";
private static final String KEY_TOKEN = "token";
private final SharedPreferences preferences;
public TokenStore(Context context) {
this.preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
public void saveToken(String token) {
preferences.edit().putString(KEY_TOKEN, token).apply();
}
public String getToken() {
return preferences.getString(KEY_TOKEN, null);
}
public void clear() {
preferences.edit().remove(KEY_TOKEN).apply();
}
}

View File

@@ -1,32 +0,0 @@
package com.carmaintenance.data.interceptor
import android.content.Context
import com.carmaintenance.data.manager.TokenManager
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// 获取Token
val token = runBlocking { tokenManager.getToken() }
val newRequest = if (token != null) {
originalRequest.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
originalRequest
}
return chain.proceed(newRequest)
}
}

View File

@@ -1,69 +0,0 @@
package com.carmaintenance.data.manager
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_prefs")
@Singleton
class TokenManager @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private val TOKEN_KEY = stringPreferencesKey("auth_token")
private val USER_KEY = stringPreferencesKey("user_info")
}
suspend fun saveToken(token: String) {
context.dataStore.edit { preferences ->
preferences[TOKEN_KEY] = token
}
}
suspend fun getToken(): String? {
return context.dataStore.data.map { preferences ->
preferences[TOKEN_KEY]
}.first()
}
suspend fun clearToken() {
context.dataStore.edit { preferences ->
preferences.remove(TOKEN_KEY)
preferences.remove(USER_KEY)
}
}
fun getTokenFlow(): Flow<String?> {
return context.dataStore.data.map { preferences ->
preferences[TOKEN_KEY]
}
}
suspend fun saveUserInfo(userInfoJson: String) {
context.dataStore.edit { preferences ->
preferences[USER_KEY] = userInfoJson
}
}
suspend fun getUserInfo(): String? {
return context.dataStore.data.map { preferences ->
preferences[USER_KEY]
}.first()
}
fun getUserInfoFlow(): Flow<String?> {
return context.dataStore.data.map { preferences ->
preferences[USER_KEY]
}
}
}

View File

@@ -1,43 +0,0 @@
package com.carmaintenance.data.model
import com.google.gson.annotations.SerializedName
/**
* 预约实体
*/
data class Appointment(
@SerializedName("appointmentId")
val appointmentId: Int,
@SerializedName("customerId")
val customerId: Int,
@SerializedName("vehicleId")
val vehicleId: Int,
@SerializedName("serviceType")
val serviceType: String,
@SerializedName("appointmentTime")
val appointmentTime: String,
@SerializedName("contactPhone")
val contactPhone: String,
@SerializedName("description")
val description: String? = null,
@SerializedName("status")
val status: String,
@SerializedName("createTime")
val createTime: String
) {
enum class Status(val displayName: String) {
PENDING("待确认"),
CONFIRMED("已确认"),
COMPLETED("已完成"),
CANCELLED("已取消");
companion object {
fun fromValue(value: String): Status {
return values().find { it.name.lowercase() == value } ?: PENDING
}
}
}
val statusEnum: Status
get() = Status.fromValue(status)
}

View File

@@ -1,23 +0,0 @@
package com.carmaintenance.data.model
import com.google.gson.annotations.SerializedName
/**
* 登录请求
*/
data class LoginRequest(
@SerializedName("username")
val username: String,
@SerializedName("password")
val password: String
)
/**
* 登录响应数据
*/
data class LoginResponseData(
@SerializedName("token")
val token: String,
@SerializedName("userInfo")
val userInfo: User
)

View File

@@ -1,84 +0,0 @@
package com.carmaintenance.data.model
import com.google.gson.annotations.SerializedName
/**
* 维保工单实体
*/
data class ServiceOrder(
@SerializedName("orderId")
val orderId: Int,
@SerializedName("orderNo")
val orderNo: String,
@SerializedName("vehicleId")
val vehicleId: Int,
@SerializedName("customerId")
val customerId: Int,
@SerializedName("serviceType")
val serviceType: String,
@SerializedName("appointmentTime")
val appointmentTime: String? = null,
@SerializedName("arrivalTime")
val arrivalTime: String? = null,
@SerializedName("startTime")
val startTime: String? = null,
@SerializedName("completeTime")
val completeTime: String? = null,
@SerializedName("staffId")
val staffId: Int? = null,
@SerializedName("currentMileage")
val currentMileage: Double? = null,
@SerializedName("faultDescription")
val faultDescription: String? = null,
@SerializedName("diagnosisResult")
val diagnosisResult: String? = null,
@SerializedName("serviceItems")
val serviceItems: String? = null,
@SerializedName("partsCost")
val partsCost: Double? = 0.0,
@SerializedName("laborCost")
val laborCost: Double? = 0.0,
@SerializedName("totalCost")
val totalCost: Double? = 0.0,
@SerializedName("status")
val status: String,
@SerializedName("paymentStatus")
val paymentStatus: String,
@SerializedName("remark")
val remark: String? = null,
@SerializedName("createTime")
val createTime: String
) {
enum class ServiceType(val displayName: String) {
MAINTENANCE("保养维护"),
REPAIR("维修服务"),
BEAUTY("美容服务"),
INSURANCE("保险代理");
companion object {
fun fromValue(value: String): ServiceType {
return values().find { it.name.lowercase() == value } ?: MAINTENANCE
}
}
}
enum class Status(val displayName: String) {
PENDING("待处理"),
APPOINTED("已预约"),
IN_PROGRESS("进行中"),
COMPLETED("已完成"),
CANCELLED("已取消");
companion object {
fun fromValue(value: String): Status {
return values().find { it.name.lowercase() == value } ?: PENDING
}
}
}
val serviceTypeEnum: ServiceType
get() = ServiceType.fromValue(serviceType)
val statusEnum: Status
get() = Status.fromValue(status)
}

View File

@@ -1,41 +0,0 @@
package com.carmaintenance.data.model
import com.google.gson.annotations.SerializedName
/**
* 用户实体
*/
data class User(
@SerializedName("userId")
val userId: Int,
@SerializedName("username")
val username: String,
@SerializedName("realName")
val realName: String,
@SerializedName("phone")
val phone: String,
@SerializedName("email")
val email: String? = null,
@SerializedName("role")
val role: String,
@SerializedName("status")
val status: Int
) {
enum class Role(val value: String) {
ADMIN("admin"),
STAFF("staff"),
CUSTOMER("customer");
companion object {
fun fromValue(value: String): Role {
return values().find { it.value == value } ?: CUSTOMER
}
}
}
val roleEnum: Role
get() = Role.fromValue(role)
val isActive: Boolean
get() = status == 1
}

View File

@@ -1,38 +0,0 @@
package com.carmaintenance.data.model
import com.google.gson.annotations.SerializedName
/**
* 车辆实体
*/
data class Vehicle(
@SerializedName("vehicleId")
val vehicleId: Int,
@SerializedName("customerId")
val customerId: Int,
@SerializedName("licensePlate")
val licensePlate: String,
@SerializedName("brand")
val brand: String,
@SerializedName("model")
val model: String,
@SerializedName("color")
val color: String? = null,
@SerializedName("vin")
val vin: String? = null,
@SerializedName("engineNo")
val engineNo: String? = null,
@SerializedName("purchaseDate")
val purchaseDate: String? = null,
@SerializedName("mileage")
val mileage: Double? = 0.0,
@SerializedName("lastMaintenanceDate")
val lastMaintenanceDate: String? = null,
@SerializedName("nextMaintenanceDate")
val nextMaintenanceDate: String? = null,
@SerializedName("status")
val status: String = "normal"
) {
val displayName: String
get() = "$licensePlate - $brand $model"
}

View File

@@ -1,18 +0,0 @@
package com.carmaintenance.data.remote
import com.google.gson.annotations.SerializedName
/**
* 通用API响应封装
*/
data class ApiResponse<T>(
@SerializedName("code")
val code: Int,
@SerializedName("message")
val message: String? = null,
@SerializedName("data")
val data: T? = null
) {
val isSuccess: Boolean
get() = code == 200
}

View File

@@ -1,97 +0,0 @@
package com.carmaintenance.data.remote
import com.carmaintenance.data.model.*
import retrofit2.http.*
/**
* API服务接口
*/
interface ApiService {
// ==================== 认证相关 ====================
@POST("auth/login")
suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponseData>
@POST("auth/logout")
suspend fun logout(): ApiResponse<Void>
// ==================== 用户相关 ====================
@GET("users")
suspend fun getUsers(): ApiResponse<List<User>>
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Int): ApiResponse<User>
// ==================== 车辆相关 ====================
@GET("vehicles")
suspend fun getVehicles(): ApiResponse<List<Vehicle>>
@GET("vehicles/{id}")
suspend fun getVehicle(@Path("id") id: Int): ApiResponse<Vehicle>
@GET("vehicles/customer/{customerId}")
suspend fun getVehiclesByCustomer(@Path("customerId") customerId: Int): ApiResponse<List<Vehicle>>
// ==================== 工单相关 ====================
@GET("orders")
suspend fun getOrders(): ApiResponse<List<ServiceOrder>>
@GET("orders/{id}")
suspend fun getOrder(@Path("id") id: Int): ApiResponse<ServiceOrder>
@GET("orders/customer/{customerId}")
suspend fun getOrdersByCustomer(@Path("customerId") customerId: Int): ApiResponse<List<ServiceOrder>>
@PUT("orders/{id}")
suspend fun updateOrder(
@Path("id") id: Int,
@Body order: ServiceOrder
): ApiResponse<ServiceOrder>
// ==================== 预约相关 ====================
@GET("appointments")
suspend fun getAppointments(): ApiResponse<List<Appointment>>
@GET("appointments/{id}")
suspend fun getAppointment(@Path("id") id: Int): ApiResponse<Appointment>
@GET("appointments/customer/{customerId}")
suspend fun getAppointmentsByCustomer(@Path("customerId") customerId: Int): ApiResponse<List<Appointment>>
@POST("appointments")
suspend fun createAppointment(@Body appointment: Appointment): ApiResponse<Appointment>
@PUT("appointments/{id}/cancel")
suspend fun cancelAppointment(@Path("id") id: Int): ApiResponse<Void>
// ==================== 客户相关 ====================
@GET("customers")
suspend fun getCustomers(): ApiResponse<List<Customer>>
@GET("customers/user/{userId}")
suspend fun getCustomerByUserId(@Path("userId") userId: Int): ApiResponse<Customer>
}
/**
* 客户实体
*/
data class Customer(
@SerializedName("customerId")
val customerId: Int,
@SerializedName("userId")
val userId: Int,
@SerializedName("customerNo")
val customerNo: String,
@SerializedName("idCard")
val idCard: String? = null,
@SerializedName("address")
val address: String? = null,
@SerializedName("gender")
val gender: String? = null,
@SerializedName("birthDate")
val birthDate: String? = null,
@SerializedName("membershipLevel")
val membershipLevel: String? = null,
@SerializedName("points")
val points: Int = 0
)

View File

@@ -1,64 +0,0 @@
package com.carmaintenance.data.repository
import com.carmaintenance.data.model.Appointment
import com.carmaintenance.data.remote.ApiResponse
import com.carmaintenance.data.remote.ApiService
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppointmentRepository @Inject constructor(
private val apiService: ApiService
) {
suspend fun getAppointments(): Result<List<Appointment>> {
return try {
val response = apiService.getAppointments()
if (response.isSuccess) {
Result.success(response.data ?: emptyList())
} else {
Result.failure(Exception(response.message ?: "获取预约列表失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getCustomerAppointments(customerId: Int): Result<List<Appointment>> {
return try {
val response = apiService.getAppointmentsByCustomer(customerId)
if (response.isSuccess) {
Result.success(response.data ?: emptyList())
} else {
Result.failure(Exception(response.message ?: "获取预约列表失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun createAppointment(appointment: Appointment): Result<Appointment> {
return try {
val response = apiService.createAppointment(appointment)
if (response.isSuccess && response.data != null) {
Result.success(response.data)
} else {
Result.failure(Exception(response.message ?: "创建预约失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun cancelAppointment(appointmentId: Int): Result<Unit> {
return try {
val response = apiService.cancelAppointment(appointmentId)
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.message ?: "取消预约失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -1,60 +0,0 @@
package com.carmaintenance.data.repository
import com.carmaintenance.data.manager.TokenManager
import com.carmaintenance.data.model.LoginRequest
import com.carmaintenance.data.model.User
import com.carmaintenance.data.remote.ApiResponse
import com.carmaintenance.data.remote.ApiService
import com.google.gson.Gson
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepository @Inject constructor(
private val apiService: ApiService,
private val tokenManager: TokenManager
) {
suspend fun login(username: String, password: String): Result<User> {
return try {
val response = apiService.login(LoginRequest(username, password))
if (response.isSuccess && response.data != null) {
// 保存Token
tokenManager.saveToken(response.data.token)
// 保存用户信息
val userJson = Gson().toJson(response.data.userInfo)
tokenManager.saveUserInfo(userJson)
Result.success(response.data.userInfo)
} else {
Result.failure(Exception(response.message ?: "登录失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun logout(): Result<Unit> {
return try {
apiService.logout()
tokenManager.clearToken()
Result.success(Unit)
} catch (e: Exception) {
tokenManager.clearToken()
Result.success(Unit)
}
}
suspend fun getCurrentUser(): User? {
return try {
val userJson = tokenManager.getUserInfo()
if (userJson != null) {
Gson().fromJson(userJson, User::class.java)
} else {
null
}
} catch (e: Exception) {
null
}
}
fun getCurrentUserFlow() = tokenManager.getUserInfoFlow()
}

View File

@@ -1,51 +0,0 @@
package com.carmaintenance.data.repository
import com.carmaintenance.data.model.ServiceOrder
import com.carmaintenance.data.remote.ApiResponse
import com.carmaintenance.data.remote.ApiService
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OrderRepository @Inject constructor(
private val apiService: ApiService
) {
suspend fun getOrders(): Result<List<ServiceOrder>> {
return try {
val response = apiService.getOrders()
if (response.isSuccess) {
Result.success(response.data ?: emptyList())
} else {
Result.failure(Exception(response.message ?: "获取工单列表失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getCustomerOrders(customerId: Int): Result<List<ServiceOrder>> {
return try {
val response = apiService.getOrdersByCustomer(customerId)
if (response.isSuccess) {
Result.success(response.data ?: emptyList())
} else {
Result.failure(Exception(response.message ?: "获取工单列表失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun updateOrder(orderId: Int, order: ServiceOrder): Result<ServiceOrder> {
return try {
val response = apiService.updateOrder(orderId, order)
if (response.isSuccess && response.data != null) {
Result.success(response.data)
} else {
Result.failure(Exception(response.message ?: "更新工单失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -1,38 +0,0 @@
package com.carmaintenance.data.repository
import com.carmaintenance.data.model.Vehicle
import com.carmaintenance.data.remote.ApiResponse
import com.carmaintenance.data.remote.ApiService
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VehicleRepository @Inject constructor(
private val apiService: ApiService
) {
suspend fun getVehicles(): Result<List<Vehicle>> {
return try {
val response = apiService.getVehicles()
if (response.isSuccess) {
Result.success(response.data ?: emptyList())
} else {
Result.failure(Exception(response.message ?: "获取车辆列表失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getCustomerVehicles(customerId: Int): Result<List<Vehicle>> {
return try {
val response = apiService.getVehiclesByCustomer(customerId)
if (response.isSuccess) {
Result.success(response.data ?: emptyList())
} else {
Result.failure(Exception(response.message ?: "获取车辆列表失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -1,63 +0,0 @@
package com.carmaintenance.di
import com.carmaintenance.BuildConfig
import com.carmaintenance.data.remote.ApiService
import com.carmaintenance.data.interceptor.AuthInterceptor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
@Provides
@Singleton
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
authInterceptor: AuthInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}

View File

@@ -1,98 +0,0 @@
package com.carmaintenance.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.carmaintenance.ui.screen.LoginScreen
import com.carmaintenance.ui.screen.admin.AdminDashboardScreen
import com.carmaintenance.ui.screen.customer.CustomerDashboardScreen
import com.carmaintenance.ui.screen.customer.CustomerAppointmentsScreen
import com.carmaintenance.ui.screen.customer.CustomerOrdersScreen
import com.carmaintenance.ui.screen.customer.CustomerVehiclesScreen
import com.carmaintenance.ui.viewmodel.AuthViewModel
import com.carmaintenance.ui.viewmodel.AuthUiState
@Composable
fun CarMaintenanceNavGraph(
navController: NavHostController,
authViewModel: AuthViewModel = hiltViewModel()
) {
val uiState by authViewModel.uiState.collectAsState()
LaunchedEffect(uiState) {
when (uiState) {
is AuthUiState.LoggedIn -> {
val user = (uiState as AuthUiState.LoggedIn).user
val route = when (user.role) {
"admin" -> Screen.AdminDashboard.route
"staff" -> Screen.StaffDashboard.route
"customer" -> Screen.CustomerDashboard.route
else -> Screen.Login.route
}
navController.navigate(route) {
popUpTo(Screen.Login.route) { inclusive = true }
}
}
is AuthUiState.NotLoggedIn -> {
navController.navigate(Screen.Login.route) {
popUpTo(0) { inclusive = true }
}
}
else -> {}
}
}
NavHost(
navController = navController,
startDestination = Screen.Login.route
) {
composable(Screen.Login.route) {
LoginScreen(
onLoginSuccess = {
// 导航由LaunchedEffect处理
}
)
}
composable(Screen.CustomerDashboard.route) {
CustomerDashboardScreen(
onNavigateBack = { authViewModel.logout() }
)
}
composable(Screen.CustomerVehicles.route) {
CustomerVehiclesScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.CustomerOrders.route) {
CustomerOrdersScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.CustomerAppointments.route) {
CustomerAppointmentsScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.AdminDashboard.route) {
AdminDashboardScreen(
onNavigateBack = { authViewModel.logout() }
)
}
composable(Screen.StaffDashboard.route) {
// TODO: 实现工作人员仪表板
}
}
}

View File

@@ -1,11 +0,0 @@
package com.carmaintenance.ui.navigation
sealed class Screen(val route: String) {
object Login : Screen("login")
object CustomerDashboard : Screen("customer_dashboard")
object CustomerVehicles : Screen("customer_vehicles")
object CustomerOrders : Screen("customer_orders")
object CustomerAppointments : Screen("customer_appointments")
object AdminDashboard : Screen("admin_dashboard")
object StaffDashboard : Screen("staff_dashboard")
}

View File

@@ -1,198 +0,0 @@
package com.carmaintenance.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.carmaintenance.ui.viewmodel.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var selectedRole by remember { mutableStateOf("customer") }
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState) {
if (uiState is AuthUiState.LoggedIn) {
onLoginSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("车管家系统") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "欢迎登录",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 48.dp)
)
// 用户名输入
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
Spacer(modifier = Modifier.height(16.dp))
// 密码输入
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (passwordVisible) "隐藏密码" else "显示密码"
)
}
}
)
Spacer(modifier = Modifier.height(16.dp))
// 角色选择
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "选择角色",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
FilterChip(
selected = selectedRole == "admin",
onClick = { selectedRole = "admin" },
label = { Text("管理员") },
modifier = Modifier.padding(end = 8.dp)
)
FilterChip(
selected = selectedRole == "staff",
onClick = { selectedRole = "staff" },
label = { Text("工作人员") },
modifier = Modifier.padding(end = 8.dp)
)
FilterChip(
selected = selectedRole == "customer",
onClick = { selectedRole = "customer" },
label = { Text("客户") }
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// 登录按钮
Button(
onClick = {
viewModel.login(username, password)
},
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
enabled = username.isNotBlank() && password.isNotBlank() && uiState !is AuthUiState.Loading
) {
if (uiState is AuthUiState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("登录", style = MaterialTheme.typography.titleMedium)
}
}
// 错误信息
if (uiState is AuthUiState.Error) {
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = (uiState as AuthUiState.Error).message,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 测试账号提示
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "测试账号",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "管理员: admin / 123456\n工作人员: staff001 / 123456\n客户: customer001 / 123456",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
}

View File

@@ -1,166 +0,0 @@
package com.carmaintenance.ui.screen.admin
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.carmaintenance.ui.viewmodel.CustomerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminDashboardScreen(
onNavigateBack: () -> Unit,
viewModel: CustomerViewModel = hiltViewModel()
) {
val vehicles by viewModel.vehicles.collectAsState()
val isLoading by viewModel.isLoadingVehicles.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("管理员控制台") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "退出")
}
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
text = "系统概览",
style = MaterialTheme.typography.titleLarge
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(title = "用户总数", value = "3", modifier = Modifier.weight(1f))
StatCard(title = "车辆总数", value = "${vehicles.size}", modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(title = "工单总数", value = "0", modifier = Modifier.weight(1f))
StatCard(title = "库存预警", value = "0", modifier = Modifier.weight(1f))
}
}
item {
Text(
text = "管理功能",
style = MaterialTheme.typography.titleLarge
)
}
item {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
AdminMenuItem(
icon = "👥",
title = "用户管理",
subtitle = "管理系统用户"
)
Divider()
AdminMenuItem(
icon = "🚗",
title = "车辆管理",
subtitle = "管理车辆档案"
)
Divider()
AdminMenuItem(
icon = "📋",
title = "工单管理",
subtitle = "管理维保工单"
)
Divider()
AdminMenuItem(
icon = "📦",
title = "配件管理",
subtitle = "管理配件库存"
)
Divider()
AdminMenuItem(
icon = "📅",
title = "预约管理",
subtitle = "管理客户预约"
)
}
}
}
}
}
}
@Composable
fun StatCard(title: String, value: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
@Composable
fun AdminMenuItem(icon: String, title: String, subtitle: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = icon,
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}

View File

@@ -1,123 +0,0 @@
package com.carmaintenance.ui.screen.customer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.carmaintenance.ui.viewmodel.CustomerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomerAppointmentsScreen(
onNavigateBack: () -> Unit,
viewModel: CustomerViewModel = hiltViewModel()
) {
val appointments by viewModel.appointments.collectAsState()
val isLoading by viewModel.isLoadingAppointments.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的预约") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { /* TODO: 打开预约表单 */ }
) {
Icon(Icons.Default.Add, contentDescription = "新建预约")
}
}
) { paddingValues ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (appointments.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text("暂无预约记录")
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(appointments) { appointment ->
AppointmentCard(appointment = appointment)
}
}
}
}
}
@Composable
fun AppointmentCard(appointment: com.carmaintenance.data.model.Appointment) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = appointment.serviceTypeEnum.displayName,
style = MaterialTheme.typography.titleMedium
)
Surface(
color = when (appointment.statusEnum) {
com.carmaintenance.data.model.Appointment.Status.PENDING -> MaterialTheme.colorScheme.tertiaryContainer
com.carmaintenance.data.model.Appointment.Status.CONFIRMED -> MaterialTheme.colorScheme.primaryContainer
com.carmaintenance.data.model.Appointment.Status.COMPLETED -> MaterialTheme.colorScheme.secondaryContainer
com.carmaintenance.data.model.Appointment.Status.CANCELLED -> MaterialTheme.colorScheme.errorContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
text = appointment.statusEnum.displayName,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "预约时间: ${appointment.appointmentTime}",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "联系电话: ${appointment.contactPhone}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}

View File

@@ -1,311 +0,0 @@
package com.carmaintenance.ui.screen.customer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.carmaintenance.data.model.Vehicle
import com.carmaintenance.ui.viewmodel.AuthViewModel
import com.carmaintenance.ui.viewmodel.CustomerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomerDashboardScreen(
onNavigateBack: () -> Unit,
viewModel: CustomerViewModel = hiltViewModel(),
authViewModel: AuthViewModel = hiltViewModel()
) {
val user by authViewModel.currentUser.collectAsState()
val vehicles by viewModel.vehicles.collectAsState()
val isLoading by viewModel.isLoadingVehicles.collectAsState()
LaunchedEffect(user) {
user?.let { viewModel.loadCustomerData(it.userId) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("客户中心") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ExitToApp, contentDescription = "退出")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
// 用户信息卡片
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = user?.realName ?: "客户",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "欢迎回来!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
}
}
}
item {
Text(
text = "快捷功能",
style = MaterialTheme.typography.titleLarge
)
}
item {
// 功能菜单
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FeatureCard(
icon = Icons.Default.DirectionsCar,
title = "我的车辆",
subtitle = "${vehicles.size}",
modifier = Modifier.weight(1f)
)
FeatureCard(
icon = Icons.Default.Receipt,
title = "维保记录",
subtitle = "查看详情",
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FeatureCard(
icon = Icons.Default.Event,
title = "我的预约",
subtitle = "在线预约",
modifier = Modifier.weight(1f)
)
FeatureCard(
icon = Icons.Default.Notifications,
title = "消息通知",
subtitle = "暂无消息",
modifier = Modifier.weight(1f)
)
}
}
item {
Text(
text = "我的车辆",
style = MaterialTheme.typography.titleLarge
)
}
if (isLoading) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
} else if (vehicles.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.DirectionsCar,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无车辆",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
} else {
items(vehicles) { vehicle ->
VehicleCard(vehicle = vehicle)
}
}
}
}
}
@Composable
fun FeatureCard(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.height(100.dp),
onClick = { /* TODO: 导航 */ }
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleSmall
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
@Composable
fun VehicleCard(vehicle: Vehicle) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = vehicle.licensePlate,
style = MaterialTheme.typography.titleLarge
)
Text(
text = "${vehicle.brand} ${vehicle.model}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "正常",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
InfoItem(
icon = Icons.Default.Palette,
label = "颜色",
value = vehicle.color ?: "-"
)
InfoItem(
icon = Icons.Default.Speed,
label = "里程",
value = "${vehicle.mileage?.toInt() ?: 0} km"
)
InfoItem(
icon = Icons.Default.DateRange,
label = "保养",
value = vehicle.lastMaintenanceDate ?: "-"
)
}
}
}
}
@Composable
fun InfoItem(icon: ImageVector, label: String, value: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall
)
}
}

View File

@@ -1,117 +0,0 @@
package com.carmaintenance.ui.screen.customer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.carmaintenance.ui.viewmodel.CustomerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomerOrdersScreen(
onNavigateBack: () -> Unit,
viewModel: CustomerViewModel = hiltViewModel()
) {
val orders by viewModel.orders.collectAsState()
val isLoading by viewModel.isLoadingOrders.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("维保记录") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
}
)
}
) { paddingValues ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (orders.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text("暂无维保记录")
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(orders) { order ->
OrderCard(order = order)
}
}
}
}
}
@Composable
fun OrderCard(order: com.carmaintenance.data.model.ServiceOrder) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = order.orderNo,
style = MaterialTheme.typography.titleMedium
)
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = order.statusEnum.displayName,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = order.serviceTypeEnum.displayName,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "费用: ¥${order.totalCost ?: 0.0}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = order.createTime,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}

View File

@@ -1,60 +0,0 @@
package com.carmaintenance.ui.screen.customer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.carmaintenance.ui.viewmodel.CustomerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomerVehiclesScreen(
onNavigateBack: () -> Unit,
viewModel: CustomerViewModel = hiltViewModel()
) {
val vehicles by viewModel.vehicles.collectAsState()
val isLoading by viewModel.isLoadingVehicles.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的车辆") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
}
)
}
) { paddingValues ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(vehicles) { vehicle ->
VehicleCard(vehicle = vehicle)
}
}
}
}
}

View File

@@ -1,26 +0,0 @@
package com.carmaintenance.ui.theme
import androidx.compose.ui.graphics.Color
// 主色调
val Primary = Color(0xFF1976D2)
val PrimaryDark = Color(0xFF1565C0)
val PrimaryLight = Color(0xFF42A5F5)
// 辅助色
val Secondary = Color(0xFF03DAC6)
val SecondaryVariant = Color(0xFF018786)
// 背景色
val Background = Color(0xFFFAFAFA)
val Surface = Color(0xFFFFFFFF)
// 状态色
val Success = Color(0xFF4CAF50)
val Warning = Color(0xFFFFC107)
val Error = Color(0xFFF44336)
val Info = Color(0xFF2196F3)
// 文字颜色
val TextPrimary = Color(0xFF212121)
val TextSecondary = Color(0xFF757575)

View File

@@ -1,68 +0,0 @@
package com.carmaintenance.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
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.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Primary,
secondary = Secondary,
error = Error
)
private val LightColorScheme = lightColorScheme(
primary = Primary,
primaryContainer = PrimaryLight,
secondary = Secondary,
secondaryContainer = SecondaryVariant,
background = Background,
surface = Surface,
error = Error,
onPrimary = androidx.compose.ui.graphics.Color.White,
onSecondary = androidx.compose.ui.graphics.Color.Black,
onBackground = TextPrimary,
onSurface = TextPrimary
)
@Composable
fun CarMaintenanceTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -1,115 +0,0 @@
package com.carmaintenance.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = 0.sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -1,81 +0,0 @@
package com.carmaintenance.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.carmaintenance.data.model.User
import com.carmaintenance.data.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<AuthUiState>(AuthUiState.Initial)
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
init {
checkLoginStatus()
}
private fun checkLoginStatus() {
viewModelScope.launch {
val user = authRepository.getCurrentUser()
if (user != null) {
_currentUser.value = user
_isLoggedIn.value = true
_uiState.value = AuthUiState.LoggedIn(user)
} else {
_isLoggedIn.value = false
_uiState.value = AuthUiState.NotLoggedIn
}
}
}
fun login(username: String, password: String) {
if (username.isBlank() || password.isBlank()) {
_uiState.value = AuthUiState.Error("用户名和密码不能为空")
return
}
viewModelScope.launch {
_uiState.value = AuthUiState.Loading
val result = authRepository.login(username, password)
result.onSuccess { user ->
_currentUser.value = user
_isLoggedIn.value = true
_uiState.value = AuthUiState.LoggedIn(user)
}.onFailure { exception ->
_uiState.value = AuthUiState.Error(exception.message ?: "登录失败")
}
}
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
_currentUser.value = null
_isLoggedIn.value = false
_uiState.value = AuthUiState.NotLoggedIn
}
}
}
sealed class AuthUiState {
object Initial : AuthUiState()
object Loading : AuthUiState()
object NotLoggedIn : AuthUiState()
data class LoggedIn(val user: User) : AuthUiState()
data class Error(val message: String) : AuthUiState()
}

View File

@@ -1,112 +0,0 @@
package com.carmaintenance.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.carmaintenance.data.model.Appointment
import com.carmaintenance.data.model.ServiceOrder
import com.carmaintenance.data.model.Vehicle
import com.carmaintenance.data.repository.AppointmentRepository
import com.carmaintenance.data.repository.OrderRepository
import com.carmaintenance.data.repository.VehicleRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CustomerViewModel @Inject constructor(
private val vehicleRepository: VehicleRepository,
private val orderRepository: OrderRepository,
private val appointmentRepository: AppointmentRepository
) : ViewModel() {
// 车辆相关
private val _vehicles = MutableStateFlow<List<Vehicle>>(emptyList())
val vehicles: StateFlow<List<Vehicle>> = _vehicles.asStateFlow()
private val _isLoadingVehicles = MutableStateFlow(false)
val isLoadingVehicles: StateFlow<Boolean> = _isLoadingVehicles.asStateFlow()
// 工单相关
private val _orders = MutableStateFlow<List<ServiceOrder>>(emptyList())
val orders: StateFlow<List<ServiceOrder>> = _orders.asStateFlow()
private val _isLoadingOrders = MutableStateFlow(false)
val isLoadingOrders: StateFlow<Boolean> = _isLoadingOrders.asStateFlow()
// 预约相关
private val _appointments = MutableStateFlow<List<Appointment>>(emptyList())
val appointments: StateFlow<List<Appointment>> = _appointments.asStateFlow()
private val _isLoadingAppointments = MutableStateFlow(false)
val isLoadingAppointments: StateFlow<Boolean> = _isLoadingAppointments.asStateFlow()
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message.asStateFlow()
fun loadCustomerData(customerId: Int) {
loadVehicles(customerId)
loadOrders(customerId)
loadAppointments(customerId)
}
fun loadVehicles(customerId: Int) {
viewModelScope.launch {
_isLoadingVehicles.value = true
vehicleRepository.getCustomerVehicles(customerId)
.onSuccess { _vehicles.value = it }
.onFailure { _message.value = it.message }
_isLoadingVehicles.value = false
}
}
fun loadOrders(customerId: Int) {
viewModelScope.launch {
_isLoadingOrders.value = true
orderRepository.getCustomerOrders(customerId)
.onSuccess { _orders.value = it }
.onFailure { _message.value = it.message }
_isLoadingOrders.value = false
}
}
fun loadAppointments(customerId: Int) {
viewModelScope.launch {
_isLoadingAppointments.value = true
appointmentRepository.getCustomerAppointments(customerId)
.onSuccess { _appointments.value = it }
.onFailure { _message.value = it.message }
_isLoadingAppointments.value = false
}
}
fun createAppointment(appointment: Appointment) {
viewModelScope.launch {
appointmentRepository.createAppointment(appointment)
.onSuccess {
_message.value = "预约创建成功"
if (appointment.customerId > 0) {
loadAppointments(appointment.customerId)
}
}
.onFailure { _message.value = it.message ?: "预约创建失败" }
}
}
fun cancelAppointment(appointmentId: Int, customerId: Int) {
viewModelScope.launch {
appointmentRepository.cancelAppointment(appointmentId)
.onSuccess {
_message.value = "预约已取消"
loadAppointments(customerId)
}
.onFailure { _message.value = it.message ?: "取消预约失败" }
}
}
fun clearMessage() {
_message.value = null
}
}

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/purple_500" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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:padding="24dp">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Car Maintenance"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
app:layout_constraintTop_toBottomOf="@id/title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/usernameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/usernameLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/password"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/loginButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/login"
app:layout_constraintTop_toBottomOf="@id/passwordLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<ProgressBar
android:id="@+id/loginProgress"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/loginButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/loginError"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="@android:color/holo_red_dark"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/loginProgress"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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:padding="24dp">
<TextView
android:id="@+id/welcomeTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Role Home"
android:textSize="22sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/userInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textSize="16sp"
app:layout_constraintTop_toBottomOf="@id/welcomeTitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/primaryAction"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Primary Action"
app:layout_constraintTop_toBottomOf="@id/userInfo"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/secondaryAction"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Secondary Action"
app:layout_constraintTop_toBottomOf="@id/primaryAction"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/logoutButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/logout"
app:layout_constraintTop_toBottomOf="@id/secondaryAction"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,6 @@
<resources>
<color name="purple_500">#6200EE</color>
<color name="purple_700">#3700B3</color>
<color name="teal_200">#03DAC5</color>
<color name="white">#FFFFFF</color>
</resources>

View File

@@ -1,3 +1,8 @@
<resources>
<string name="app_name">车管家</string>
<string name="app_name">Car Maintenance</string>
<string name="login">Login</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="role_home">Role Home</string>
<string name="logout">Logout</string>
</resources>

View File

@@ -1,4 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CarMaintenance" parent="android:Theme.Material.Light.NoActionBar" />
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.CarMaintenance" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="colorOnSecondary">@color/white</item>
</style>
</resources>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="user_prefs.xml"/>
</full-backup-content>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="user_prefs.xml"/>
</cloud-backup>
</data-extraction-rules>

3
android/build.gradle Normal file
View File

@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.2.2' apply false
}

View File

@@ -1,7 +0,0 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
id("com.google.dagger.hilt.android") version "2.48" apply false
id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
}

View File

@@ -1,6 +1,3 @@
# Gradle properties
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
kotlin.code.style=official
android.nonTransitiveRClass=false
android.nonTransitiveRClass=true

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

234
android/gradlew vendored
View File

@@ -1,7 +1,6 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -14,238 +13,15 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
APP_HOME=$(cd "${0%/*}" && pwd -P)
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
JAVA_EXEC="$JAVA_HOME/bin/java"
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
JAVA_EXEC="java"
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
exec "$JAVA_EXEC" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

75
android/gradlew.bat vendored
View File

@@ -1,4 +1,3 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,83 +11,19 @@
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set JAVA_EXE=java.exe
if not "%JAVA_HOME%" == "" set JAVA_EXE=%JAVA_HOME%\bin\java.exe
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
endlocal

View File

@@ -5,6 +5,7 @@ pluginManagement {
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@@ -13,5 +14,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "CarMaintenance"
rootProject.name = "car-maintenance-android"
include(":app")