diff --git a/VisualNovel/app/build.gradle.kts b/VisualNovel/app/build.gradle.kts
index 9a66c87..d574124 100644
--- a/VisualNovel/app/build.gradle.kts
+++ b/VisualNovel/app/build.gradle.kts
@@ -124,6 +124,13 @@ android {
buildConfigString("ABOUT_US", "https://www.xxxxx.ai/about")
buildConfigString("API_FROG", "https://www.test-frog.xxxxx.ai")
buildConfigString("EPAL_TERMS_SERVICES", "https://www.xxxxx.ai/policy/tos")
+ buildConfigString("API_SHARK", "https://test-shark.xxxxx.ai")
+ buildConfigString("API_COW", "https://test-cow.xxxxx.ai")
+ buildConfigString("API_PIGEON", "https://test-pigeon.xxxx.ai")
+ buildConfigString("API_LION", "https://test-lion.xxxx.ai")
+ buildConfigString("RECHAEGE_SERVICES", "https://test.xxxxx.ai/policy/recharge")
+
+ buildConfigString("RTC_APP_ID", "689ade491323ae01797818e0-XXX-TODO")
}
@@ -135,6 +142,13 @@ android {
buildConfigString("ABOUT_US", "https://test.xxxxx.ai/about")
buildConfigString("API_FROG", "https://test-frog.xxxxx.ai")
buildConfigString("EPAL_TERMS_SERVICES", "https://test.xxxxx.ai/policy/tos")
+ buildConfigString("API_SHARK", "https://test-shark.xxxxx.ai")
+ buildConfigString("API_COW", "https://test-cow.xxxxx.ai")
+ buildConfigString("API_PIGEON", "https://test-pigeon.xxxx.ai")
+ buildConfigString("API_LION", "https://test-lion.xxxx.ai")
+ buildConfigString("RECHAEGE_SERVICES", "https://test.xxxxx.ai/policy/recharge")
+
+ buildConfigString("RTC_APP_ID", "689ade491323ae01797818e0-XXX-TODO")
}
}
}
@@ -274,6 +288,19 @@ dependencies {
implementation(Deps.exoplayer)
implementation(Deps.subsamplingScaleImageView)
+ //s3图片上传 oss
+ implementation(Deps.awsS3)
+ implementation(Deps.awsCore)
+
+ // 网易 云信
+ implementation(Deps.nimBase)
+ implementation(Deps.nimPush)
+
+ //内购 / 充值
+ implementation(Deps.billing)
+
+ // RTC : 实时通信
+ implementation(Deps.BytePlusRTC)
implementation(project(mapOf("path" to ":loadingstateview")))
diff --git a/VisualNovel/app/src/main/AndroidManifest.xml b/VisualNovel/app/src/main/AndroidManifest.xml
index 3a564f6..f247fad 100644
--- a/VisualNovel/app/src/main/AndroidManifest.xml
+++ b/VisualNovel/app/src/main/AndroidManifest.xml
@@ -73,7 +73,7 @@
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/AIService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/AIService.kt
new file mode 100644
index 0000000..f135566
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/AIService.kt
@@ -0,0 +1,222 @@
+package com.remax.visualnovel.api.service
+
+import com.remax.visualnovel.BuildConfig
+import com.remax.visualnovel.entity.request.AIGenerate
+import com.remax.visualnovel.entity.request.AIGenerateImage
+import com.remax.visualnovel.entity.request.AIHeadImgRequest
+import com.remax.visualnovel.entity.request.AIIDRequest
+import com.remax.visualnovel.entity.request.AlbumCreate
+import com.remax.visualnovel.entity.request.AlbumDTO
+import com.remax.visualnovel.entity.request.CardRequest
+import com.remax.visualnovel.entity.request.ChatAlbum
+import com.remax.visualnovel.entity.request.ClassificationRequest
+import com.remax.visualnovel.entity.request.Gift
+import com.remax.visualnovel.entity.request.QueryAlbumDTO
+import com.remax.visualnovel.entity.request.SimpleCountDTO
+import com.remax.visualnovel.entity.response.Album
+import com.remax.visualnovel.entity.response.AlbumCreateCountOutput
+import com.remax.visualnovel.entity.response.AppearanceImage
+import com.remax.visualnovel.entity.response.Character
+import com.remax.visualnovel.entity.response.ContentRes
+import com.remax.visualnovel.entity.response.ExploreInfo
+import com.remax.visualnovel.entity.response.MeetSdOutput
+import com.remax.visualnovel.entity.response.Pageable
+import com.remax.visualnovel.entity.response.base.Response
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface AIService {
+
+ /**
+ * 卡片上报绑定
+ */
+ @POST("/web/meet/bd")
+ suspend fun cardBind(@Body request: AIIDRequest): Response
+
+ /**
+ * 卡片被喜欢推荐
+ */
+ @POST("/web/meet/rc")
+ suspend fun cardLiked(): Response
+
+ /**
+ * 卡片上报
+ */
+ @POST("/web/meet/sd")
+ suspend fun reportCard(@Body request: CardRequest): Response
+
+ /**
+ * 获取首页卡片列表
+ */
+ @POST("/web/home/rm-list")
+ suspend fun getHomeCard(@Body request: ClassificationRequest): Response>
+
+
+ /**
+ * 获取单个首页卡片
+ */
+ @POST(" /web/home/meet-detail")
+ suspend fun getHomeCardDetail(@Body request: AIIDRequest): Response
+
+ /**
+ * 获取分类列表
+ */
+ @POST("/web/home/classification-list")
+ suspend fun getClassificationList(@Body request: ClassificationRequest): Response>
+
+ /**
+ * 获取榜单
+ */
+ @POST("/web/rank/heartbeat")
+ suspend fun getHeartbeatRank(): Response>
+
+ /**
+ * 获取榜单
+ */
+ @POST("/web/rank/gift")
+ suspend fun getGiftRank(): Response>
+
+ /**
+ * 获取榜单
+ */
+ @POST("/web/rank/chat")
+ suspend fun getChatRank(): Response>
+
+ /**
+ * 获取发现页顶部数据
+ */
+ @POST("/web/explore/info")
+ suspend fun getExploreInfo(): Response
+
+ /**
+ * 解锁加密图片
+ */
+ @POST("/web/ai-user/unlock-album-img")
+ suspend fun unlockAlbum(@Body dto: ChatAlbum): Response
+
+ /**
+ * 解锁秘密爱慕者
+ */
+ @POST("/web/meet/unlock")
+ suspend fun unlockSecret(@Body dto: AIIDRequest): Response
+
+ /**
+ * 设置当前图片价格
+ */
+ @POST("/web/ai-user/set-album-unlock-price")
+ suspend fun setAlbumUnlockPrice(@Body dto: AlbumDTO): Response
+
+ /**
+ * 删除AI角色
+ */
+ @POST("/web/ai-user/del")
+ suspend fun deleteAICharacter(@Body request: Character): Response
+
+ /**
+ * 设置当前默认图片
+ */
+ @POST("/web/ai-user/set-default-album")
+ suspend fun setAlbumDefault(@Body dto: AlbumDTO): Response
+
+ @POST("/web/ai-user/create-edit")
+ suspend fun createOrEditAICharacter(@Body request: Character): Response
+
+ @POST("/web/ai-user/edit-head-img")
+ suspend fun editAIAvatar(@Body request: AIHeadImgRequest): Response
+
+
+ @POST(BuildConfig.API_COW + "/web/gen/user-content-v1")
+ suspend fun generateAICharacter(@Body request: AIGenerate): Response
+
+ /**
+ * 编辑时获取我的ai角色信息
+ */
+ @POST("/web/ai-user/get-my-ai-user/info")
+ suspend fun getAICharacter(@Body request: Character): Response
+
+ /**
+ * 访问AI个人主页时获取信息
+ */
+ @POST("/web/ai-user-search/base-info")
+ suspend fun getAICharacterProfile(@Body request: Character): Response
+
+ /**
+ * 访问AI的统计信息
+ */
+ @POST("/web/ai-user/stat")
+ suspend fun getAICharacterStat(@Body request: Character): Response
+
+ /**
+ * 修改点赞状态
+ */
+ @POST("/web/ai-user/like-or-cancel")
+ suspend fun setAILikeOrCancel(@Body request: AlbumDTO): Response
+
+ /**
+ * 喜欢或取消喜欢相片
+ */
+ @POST("/web/album/like_or_cancel")
+ suspend fun setLikeOrDislike(@Body dto: AlbumDTO): Response
+
+ /**
+ * 删除相片
+ */
+ @POST("/web/ai-user/album-del")
+ suspend fun deleteAlbum(@Body dto: AlbumDTO): Response
+
+ /**
+ * 批量添加图片到相册
+ */
+ @POST("/web/ai-user/batch-add-album")
+ suspend fun addAlbum(@Body dto: AlbumCreate): Response
+
+ /**
+ * 获取创作次数
+ */
+ @POST("/web/user/get-user-create-count")
+ suspend fun getAlbumCreateCount(): Response
+
+ /**
+ * 购买创作次数
+ */
+ @POST("/web/ai/buy-create-image-count")
+ suspend fun buyAlbumCreateCount(@Body dto: SimpleCountDTO): Response
+
+ /**
+ * 批量添加图片到聊天背景
+ */
+ @POST("/web/chat-background/batch-add")
+ suspend fun addChatBackground(@Body dto: AlbumCreate): Response
+
+ /**
+ * 获取相册 分页
+ */
+ @POST("/web/ai-user/album-list")
+ suspend fun getAlbumList(@Body dto: QueryAlbumDTO): Response>
+
+ /**
+ * 获取用户礼物 分页
+ */
+ @POST("/web/ai-user-gift/list")
+ suspend fun getUserGiftList(@Body dto: QueryAlbumDTO): Response>
+
+
+ /**
+ * AI一键生成-创建生成人物形象图片任务
+ */
+ @POST(BuildConfig.API_COW + "/web/gen/image-ct")
+ suspend fun generateImageBatch(@Body request: AIGenerateImage): Response
+
+ /**
+ * AI一键生成-删除图片生成任务
+ */
+ @POST(BuildConfig.API_COW + "/web/gen/del")
+ suspend fun generateImageBatchDel(@Body request: AIGenerateImage): Response
+
+ /**
+ * AI一键生成-轮询查询图片生成结果
+ */
+ @POST(BuildConfig.API_COW + "/web/gen/image-pl")
+ suspend fun generateImageBatchQuery(@Body request: AIGenerateImage): Response>
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/ChatService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/ChatService.kt
new file mode 100644
index 0000000..3743d08
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/ChatService.kt
@@ -0,0 +1,159 @@
+package com.remax.visualnovel.api.service
+
+import com.remax.visualnovel.BuildConfig
+import com.remax.visualnovel.entity.request.AIFeedback
+import com.remax.visualnovel.entity.request.AIIDRequest
+import com.remax.visualnovel.entity.request.AIIsShowDTO
+import com.remax.visualnovel.entity.request.ChatAlbum
+import com.remax.visualnovel.entity.request.ChatSetting
+import com.remax.visualnovel.entity.request.HeartbeatBuy
+import com.remax.visualnovel.entity.request.RTCRequest
+import com.remax.visualnovel.entity.request.SearchPage
+import com.remax.visualnovel.entity.request.SimpleDataDTO
+import com.remax.visualnovel.entity.request.VoiceTTS
+import com.remax.visualnovel.entity.response.Album
+import com.remax.visualnovel.entity.response.Character
+import com.remax.visualnovel.entity.response.ChatBackground
+import com.remax.visualnovel.entity.response.ChatSet
+import com.remax.visualnovel.entity.response.Friends
+import com.remax.visualnovel.entity.response.HeartbeatLevelOutput
+import com.remax.visualnovel.entity.response.Pageable
+import com.remax.visualnovel.entity.response.Token
+import com.remax.visualnovel.entity.response.VoiceASR
+import com.remax.visualnovel.entity.response.base.Response
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface ChatService {
+
+ /**
+ * 发送开场白消息
+ */
+ @POST("/web/chat/send-dialogue-prologue-message")
+ suspend fun sendDialogueMsg(@Body request: AIIDRequest): Response
+
+ /**
+ * 获取IM中AI的基础信息
+ */
+ @POST("/web/ai-user-search/im-base-info")
+ suspend fun getIMAICharacterProfile(@Body request: Character): Response
+
+ /**
+ * 访问解锁加密图片
+ */
+ @POST("/web/ai-user/view-unlock-album-img")
+ suspend fun viewAlbumImg(@Body request: ChatAlbum): Response
+
+ /**
+ * 关系列表
+ */
+ @POST("/web/ai-user/heartbeat-relation-list")
+ suspend fun getMyFriends(@Body request: SearchPage): Response>
+
+
+ @POST("/web/ai-user/heartbeat-rank")
+ suspend fun getMyFriendRank(): Response
+
+ /**
+ * 生成提示词
+ */
+ @POST(BuildConfig.API_COW + "/web/gen/sup-content-v2")
+ suspend fun getPrompts(@Body request: AIIDRequest): Response>
+
+ /**
+ * AI回话点赞/点踩
+ */
+ @POST(BuildConfig.API_PIGEON + "/web/fb/v1")
+ suspend fun aiFeedback(@Body request: AIFeedback): Response
+
+ /**
+ * 获取RTC
+ */
+ @POST(BuildConfig.API_COW + "/web/voice-chat/gen-rtc-tk")
+ suspend fun getRTCToken(@Body request: RTCRequest): Response
+
+ /**
+ * 操作通话
+ */
+ @POST(BuildConfig.API_COW + "/web/voice-chat/opt")
+ suspend fun voiceChatOpt(@Body request: RTCRequest): Response
+
+ /**
+ * 获取聊天背景列表
+ */
+ @POST("/web/chat-background/list")
+ suspend fun getChatBackgroundList(@Body request: AIIDRequest): Response>
+
+ /**
+ * 获取聊天设置
+ */
+ @POST("/web/chat-set/get-my")
+ suspend fun getChatSetting(@Body request: ChatSetting): Response
+
+ /**
+ * 修改聊天设置
+ */
+ @POST("/web/chat-set/set")
+ suspend fun setChatSetting(@Body request: ChatSet): Response
+
+ /**
+ * 修改聊天气泡
+ */
+ @POST("/web/chat-set/set-chat-bubble")
+ suspend fun setChatBubble(@Body request: ChatSetting): Response
+
+ /**
+ * 修改聊天模型
+ */
+ @POST("/web/chat-set/set-chat-model")
+ suspend fun setChatModel(@Body request: ChatSetting): Response
+
+ /**
+ * 修改是否自动播放语音
+ */
+ @POST("/web/chat-set/auto-play-voice")
+ suspend fun setChatAutoPlay(@Body request: ChatSetting): Response
+
+ /**
+ * 修改聊天背景图
+ */
+ @POST("/web/chat-background/set-background")
+ suspend fun setChatBackground(@Body request: ChatSetting): Response
+
+ /**
+ * 删除聊天背景图
+ */
+ @POST("/web/chat-background/del")
+ suspend fun deleteChatBackground(@Body request: ChatSetting): Response
+
+ /**
+ * 展示心动关系开关
+ */
+ @POST("/web/ai-user/heartbeat-relation-switch")
+ suspend fun relationSwitch(@Body request: AIIsShowDTO): Response
+
+ /**
+ * 语音转文本
+ */
+ @POST(BuildConfig.API_COW + "/web/voice/asr-v2")
+ suspend fun voiceASR(@Body request: SimpleDataDTO): Response
+
+ /**
+ * 生成语音
+ */
+ @POST(BuildConfig.API_COW + "/web/voice/tts-v2")
+ suspend fun voiceTTS(@Body request: VoiceTTS): Response
+
+ /**
+ * 获取心动等级
+ */
+ @POST("/web/ai-user/heartbeat-level")
+ suspend fun getHeartbeatLevel(@Body request: Character): Response
+
+ /**
+ * 购买心动值
+ */
+ @POST("/web/ai-user/buy-heartbeat-val")
+ suspend fun buyHeartbeatVal(@Body request: HeartbeatBuy): Response
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt
index 343dd38..40f60e7 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt
@@ -1,6 +1,13 @@
package com.remax.visualnovel.api.service
+import com.remax.visualnovel.entity.request.AIIDRequest
+import com.remax.visualnovel.entity.request.Gift
+import com.remax.visualnovel.entity.request.PageQuery
+import com.remax.visualnovel.entity.response.AIDict
+import com.remax.visualnovel.entity.response.ChatBubble
+import com.remax.visualnovel.entity.response.ChatModel
+import com.remax.visualnovel.entity.response.Pageable
import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body
import retrofit2.http.POST
@@ -10,24 +17,24 @@ interface DictService {
/**
* 获取聊天气泡字典
*/
- /*@POST("/web/chat-set/get-chat-bubble-list")
- suspend fun getChatBubbleList(@Body request: AIIDRequest): Response>*/
+ @POST("/web/chat-set/get-chat-bubble-list")
+ suspend fun getChatBubbleList(@Body request: AIIDRequest): Response>
/**
* AI标签
*/
- /*@POST("/web/get-ai-dict")
- suspend fun getAIDict(): Response*/
+ @POST("/web/get-ai-dict")
+ suspend fun getAIDict(): Response
/**
* 礼物字典
*/
- /*@POST("/web/gift/dict-list")
- suspend fun getGiftDict(@Body pageQuery: PageQuery = PageQuery(1).apply { page.ps = 100 }): Response>*/
+ @POST("/web/gift/dict-list")
+ suspend fun getGiftDict(@Body pageQuery: PageQuery = PageQuery(1).apply { page.ps = 100 }): Response>
/**
* chat模型
*/
- /*@POST("/web/chat-model/dict-list")
- suspend fun getAIChatModel(): Response>*/
+ @POST("/web/chat-model/dict-list")
+ suspend fun getAIChatModel(): Response>
}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt
index fcba604..fd60258 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt
@@ -1,34 +1,40 @@
package com.remax.visualnovel.api.service
import com.remax.visualnovel.BuildConfig
+import com.remax.visualnovel.entity.request.AIListRequest
+import com.remax.visualnovel.entity.request.PageQuery
+import com.remax.visualnovel.entity.request.SendGift
+import com.remax.visualnovel.entity.response.MessageListOutput
+import com.remax.visualnovel.entity.response.MessageStatOutput
+import com.remax.visualnovel.entity.response.Pageable
import com.remax.visualnovel.entity.response.base.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface MessageService {
-// /**
-// * 删除会话
-// */
-// @POST(BuildConfig.API_COW + "/web/ai-message/del")
-// suspend fun deleteConversation(@Body request: AIListRequest): Response
-//
-// /**
-// * 送礼物
-// */
-// @POST("/web/ai-user-gift/send")
-// suspend fun sendGift(@Body dto: SendGift): Response
-//
-// /**
-// * 未读消息统计
-// */
-// @POST(BuildConfig.API_PIGEON + "/web/message/stat")
-// suspend fun getMessageStat(): Response
-//
-// /**
-// * 系统通知列表
-// */
-// @POST(BuildConfig.API_PIGEON + "/web/message/list")
-// suspend fun getMessageList(@Body dto: PageQuery): Response>
+ /**
+ * 删除会话
+ */
+ @POST(BuildConfig.API_COW + "/web/ai-message/del")
+ suspend fun deleteConversation(@Body request: AIListRequest): Response
+
+ /**
+ * 送礼物
+ */
+ @POST("/web/ai-user-gift/send")
+ suspend fun sendGift(@Body dto: SendGift): Response
+
+ /**
+ * 未读消息统计
+ */
+ @POST(BuildConfig.API_PIGEON + "/web/message/stat")
+ suspend fun getMessageStat(): Response
+
+ /**
+ * 系统通知列表
+ */
+ @POST(BuildConfig.API_PIGEON + "/web/message/list")
+ suspend fun getMessageList(@Body dto: PageQuery): Response>
}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/OssService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/OssService.kt
new file mode 100644
index 0000000..b4a0a98
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/OssService.kt
@@ -0,0 +1,39 @@
+package com.remax.visualnovel.api.service
+
+import com.remax.visualnovel.BuildConfig
+import com.remax.visualnovel.entity.request.ImgCheckDTO
+import com.remax.visualnovel.entity.request.S3TypeDTO
+import com.remax.visualnovel.entity.request.SimpleContentDTO
+import com.remax.visualnovel.entity.response.BucketBean
+import com.remax.visualnovel.entity.response.base.Response
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+/**
+ * OSS文件上传
+ *
+ */
+interface OssService {
+ /**
+ * 获取aws s3 bucket信息
+ */
+ @POST(BuildConfig.API_SHARK + "/web/file/sts-tk")
+ suspend fun getS3Bucket(@Body dto: S3TypeDTO): Response
+
+ /**
+ * 图片鉴黄
+ */
+ @POST(BuildConfig.API_SHARK + "/web/file/check")
+ suspend fun checkS3Img(
+ @Body imgCheckDTO: ImgCheckDTO
+ ): Response
+
+ /**
+ * 关键字校验
+ */
+ @POST("/web/check_text")
+ suspend fun checkText(
+ @Body simpleContentDTO: SimpleContentDTO
+ ): Response
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/PayService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/PayService.kt
new file mode 100644
index 0000000..5e95e48
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/PayService.kt
@@ -0,0 +1,87 @@
+package com.remax.visualnovel.api.service
+
+import com.remax.visualnovel.BuildConfig
+import com.remax.visualnovel.entity.request.ChargeOrderDTO
+import com.remax.visualnovel.entity.request.ChargeProductDTO
+import com.remax.visualnovel.entity.request.ChargeProductInfo
+import com.remax.visualnovel.entity.request.SearchPage
+import com.remax.visualnovel.entity.request.SubPriceDTO
+import com.remax.visualnovel.entity.request.ValidateTransactionDTO
+import com.remax.visualnovel.entity.response.ChargeOrder
+import com.remax.visualnovel.entity.response.Membership
+import com.remax.visualnovel.entity.response.SubPrice
+import com.remax.visualnovel.entity.response.Transaction
+import com.remax.visualnovel.entity.response.UserSubInfo
+import com.remax.visualnovel.entity.response.Wallet
+import com.remax.visualnovel.entity.response.base.Response
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface PayService {
+ /**
+ * 获取我的流水
+ */
+ @POST(BuildConfig.API_LION + "/web/pay/account/bill-list")
+ suspend fun getTransactionList(@Body request: SearchPage): Response
+
+ /**
+ * 获取我的钱包
+ */
+ @POST(BuildConfig.API_LION + "/web/pay/account/wallet")
+ suspend fun getMyWallet(): Response
+
+ /**
+ * 获取充值产品
+ */
+ @POST(BuildConfig.API_LION + "/web/pay/config/charge-product-list")
+ suspend fun getChargeProducts(
+ @Body dto: ChargeProductDTO = ChargeProductDTO()
+ ): Response
+
+
+ /**
+ * 获取vip订阅价格列表
+ */
+ @POST(BuildConfig.API_LION + "/web/pay/config/sub-product-list")
+ suspend fun getSubPriceList(
+ @Body subPriceDTO: SubPriceDTO = SubPriceDTO()
+ ): Response>
+
+ /**
+ * 会员特权列表
+ */
+ @POST(BuildConfig.API_LION + "/web/member/detail")
+ suspend fun getVipPrivilegeList(): Response
+
+ /**
+ * 创建一个订单
+ */
+ @POST(BuildConfig.API_LION +"/web/pay/trade/pre-charge-google")
+ suspend fun createOrder(
+ @Body dto: ChargeOrderDTO
+ ): Response
+
+ /**
+ * 验证支付是否成功
+ */
+ @POST(BuildConfig.API_LION +"/web/pay/webhooks/google/v2")
+ suspend fun validateTranslation(
+ @Body dto: ValidateTransactionDTO
+ ): Response
+
+ /**
+ * 验证订阅是否成功
+ */
+ @POST(BuildConfig.API_LION +"/web/pay/subscribe/upload-google-receipt")
+ suspend fun uploadGoogleReceipt(
+ @Body dto: ValidateTransactionDTO
+ ): Response
+
+ /**
+ * 订阅/升级VIP前查询订阅信息
+ */
+ @POST(BuildConfig.API_LION +"/web/pay/appStore/getUserSubscription")
+ suspend fun checkSubInfo(): Response
+
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt
index 0420e5d..b9c116d 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt
@@ -2,10 +2,14 @@ package com.remax.visualnovel.app.di
import com.remax.visualnovel.api.factory.ServiceFactory
+import com.remax.visualnovel.api.service.AIService
import com.remax.visualnovel.api.service.BookService
+import com.remax.visualnovel.api.service.ChatService
import com.remax.visualnovel.api.service.DictService
import com.remax.visualnovel.api.service.LoginService
import com.remax.visualnovel.api.service.MessageService
+import com.remax.visualnovel.api.service.OssService
+import com.remax.visualnovel.api.service.PayService
import com.remax.visualnovel.api.service.UserService
import dagger.Module
import dagger.Provides
@@ -40,6 +44,22 @@ object ApiServiceModule {
@Provides
fun bookService() = create()
+ @Singleton
+ @Provides
+ fun aiService() = create()
+
+ @Singleton
+ @Provides
+ fun ossService() = create()
+
+ @Singleton
+ @Provides
+ fun payService() = create()
+
+ @Singleton
+ @Provides
+ fun chatService() = create()
+
private inline fun create(): T {
return ServiceFactory.createService()
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/OssViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/OssViewModel.kt
new file mode 100644
index 0000000..e81ec90
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/OssViewModel.kt
@@ -0,0 +1,271 @@
+package com.remax.visualnovel.app.viewmodel.base
+
+import android.graphics.BitmapFactory
+import com.amazonaws.auth.BasicSessionCredentials
+import com.amazonaws.mobileconnectors.s3.transferutility.TransferListener
+import com.amazonaws.mobileconnectors.s3.transferutility.TransferState
+import com.amazonaws.mobileconnectors.s3.transferutility.TransferUtility
+import com.amazonaws.services.s3.AmazonS3Client
+import com.amazonaws.services.s3.S3ClientOptions
+import com.amazonaws.services.s3.model.ObjectMetadata
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.base.app.CommonApplicationProxy
+import com.remax.visualnovel.constant.StatusCode
+import com.remax.visualnovel.entity.request.ImgCheckDTO
+import com.remax.visualnovel.entity.response.BucketBean
+import com.remax.visualnovel.entity.response.base.ApiFailedResponse
+import com.remax.visualnovel.entity.response.base.ApiSuccessResponse
+import com.remax.visualnovel.entity.response.base.Response
+import com.remax.visualnovel.extension.resumeWithActive
+import com.remax.visualnovel.repository.api.OssRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.suspendCancellableCoroutine
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+
+/**
+ * Created by HJW on 2022/11/9
+ *
+ * oss上传相关
+ */
+@HiltViewModel
+open class OssViewModel @Inject constructor() : UserViewModel() {
+
+ @Inject
+ lateinit var ossRepository: OssRepository
+
+ data class LoadFileData(
+ var isSuccess: Boolean,
+ val isViolation: Boolean = false,
+ val errorMsg: String = "",
+ var urlPath: String = "",
+ var filePath: String = "",
+ var width: Int = 0,
+ var height: Int = 0
+ )
+
+ data class FileUpLoadRes(
+ val loadFileData: LoadFileData,
+ val fileOption: FileOption? = null,
+ )
+
+ data class FileOption(
+ val path: String,
+ val urlPath: String,
+ val ossType: String,
+ val width: Int,
+ val height: Int
+ )
+
+ /**
+ * 请求oss的token
+ */
+ suspend fun getBucketToken(postfix: String, ossType: String): Response {
+ return ossRepository.getS3Bucket(ossType, postfix)
+ }
+
+ /**
+ * 挂起函数上传图片
+ * @param filePath String
+ * @param ossType String
+ * @param isImg Boolean
+ * @param checkNSFW Boolean 是否鉴黄
+ * @param checkRealPerson Boolean 是否鉴定真人
+ * @param checkKid Boolean 是否鉴定儿童
+ * @return Response 封装成服务器返回一致类型处理
+ */
+ suspend fun ossUploadFile(
+ filePath: String,
+ ossType: String,
+ isImg: Boolean = true,
+ checkNSFW: Boolean = true,
+ checkRealPerson: Boolean = false,
+ checkKid: Boolean = false,
+ token: BucketBean? = null
+ ): Response {
+ /**
+ * 获取S3 STS Token对象
+ */
+ var s3BucketRes = token
+ if (s3BucketRes == null) {
+ val postfix = if (filePath.isNotEmpty()) filePath.substring(filePath.lastIndexOf(".") + 1) else "png"
+ val getTokenRes = getBucketToken(postfix, ossType)
+ //请求s3 token失败
+ if (!getTokenRes.isApiSuccess) {
+ return ApiFailedResponse(
+ errorMsg = getTokenRes.errorMsg,
+ errorData = createNormalErrorFileData(filePath).loadFileData
+ )
+ } else {
+ s3BucketRes = getTokenRes.data!!
+ }
+ }
+ val uploadRes = uploadFile(s3BucketRes, isImg, filePath, ossType)
+ //上传图片失败
+ if (!uploadRes.loadFileData.isSuccess) {
+ return ApiFailedResponse(errorMsg = uploadRes.loadFileData.errorMsg, errorData = uploadRes.loadFileData)
+ }
+ //如果不是图片 或者 不需要鉴黄、鉴定真人、鉴定儿童,直接返回成功结果
+ if (!isImg || (!checkNSFW && !checkRealPerson && !checkKid)) {
+ return ApiSuccessResponse(uploadRes.loadFileData)
+ }
+ val fileOption = uploadRes.fileOption!!
+ val checkDTO = ImgCheckDTO(fileOption.ossType, fileOption.path)
+ //鉴黄
+ val checkNSFWRes = if (checkNSFW) ossRepository.checkS3Img(checkDTO) else ApiSuccessResponse()
+ return when {
+ checkNSFWRes.isApiSuccess -> {
+ ApiSuccessResponse(uploadRes.loadFileData)
+ }
+
+ else -> {
+ ApiFailedResponse(StatusCode.UPLOAD_FILE_VIOLATION.code, checkNSFWRes.errorMsg, uploadRes.loadFileData)
+ }
+ }
+ }
+
+ /**
+ * 包装上传失败的实体
+ * @param filePath String 本地图片地址
+ * @return FileUpLoadRes
+ */
+ private fun createNormalErrorFileData(filePath: String) = FileUpLoadRes(
+ LoadFileData(
+ isSuccess = false,
+ errorMsg = CommonApplicationProxy.application.getString(R.string.upload_error),
+ filePath = filePath
+ )
+ )
+
+ /**
+ * 协程处理亚马逊上传图片
+ *
+ * 使用带取消回调的协程,当上传回调时,需要判断协程isActive以防报错崩溃
+ * @param stsToken BucketBean 授权信息
+ * @param isImg Boolean 是否是图片
+ * @param filePath String 本地地址
+ * @param ossType String 上传类型
+ * @return FileUpLoadRes 返回结果封装
+ */
+ private suspend fun uploadFile(
+ stsToken: BucketBean,
+ isImg: Boolean,
+ filePath: String,
+ ossType: String,
+ ) = suspendCancellableCoroutine {
+ it.invokeOnCancellation { _ ->
+ it.resumeWithActive(createNormalErrorFileData(filePath))
+ }
+
+ val awsCreds = BasicSessionCredentials(
+ stsToken.accessKeyId,
+ stsToken.accessKeySecret,
+ stsToken.securityToken
+ )
+ val uploadClient = AmazonS3Client(
+ awsCreds,
+ com.amazonaws.regions.Region.getRegion(stsToken.region)
+ ).apply {
+ setS3ClientOptions(
+ S3ClientOptions.builder()
+ .setAccelerateModeEnabled(false)
+ .build()
+ )
+ }
+ val transferUtility = TransferUtility.builder()
+ .s3Client(uploadClient)
+ .context(CommonApplicationProxy.application)
+ .build()
+
+ val fileName = filePath.substring(filePath.lastIndexOf("/") + 1)
+ val path = if (stsToken.path.endsWith("*")) {
+ stsToken.path.replace("*", fileName)
+ } else {
+ stsToken.path
+ }
+ val urlPath = if (stsToken.urlPath.endsWith("*")) {
+ stsToken.urlPath.replace("*", fileName)
+ } else {
+ stsToken.urlPath
+ }
+ Timber.d("oss上传 - AmazonS3 Token path:$path urlPath:$urlPath")
+
+ val obj = ObjectMetadata()
+ obj.addUserMetadata("x-amz-tagging", "temp=1")
+
+ val transferListener = object :
+ TransferListener {
+ override fun onStateChanged(id: Int, state: TransferState?) {
+ Timber.d("oss上传 - AmazonS3 onStateChanged:$state")
+ Timber.d("oss上传 - 协程状态 isActive: ${it.isActive} isCancelled: ${it.isCancelled} isCompleted: ${it.isCompleted}")
+ when (state) {
+ TransferState.COMPLETED -> {
+ //此方法是上传图片完成后再打标签
+// uploadClient.setObjectTagging(SetObjectTaggingRequest(stsToken.bucket, stsToken.path, ObjectTagging(listOf(Tag("temp", "1")))))
+ if (it.isActive) {
+ if (isImg) {
+ val options = BitmapFactory.Options()
+ options.inJustDecodeBounds = true
+ BitmapFactory.decodeFile(filePath, options)
+ val wid = options.outWidth
+ val hei = options.outHeight
+ val res = FileUpLoadRes(
+ LoadFileData(
+ isSuccess = true,
+ urlPath = urlPath,
+ width = wid,
+ height = hei,
+ filePath = filePath
+ ),
+ FileOption(path, urlPath, ossType, wid, hei)
+ )
+ it.resumeWithActive(res)
+ } else {
+ val res = FileUpLoadRes(
+ LoadFileData(
+ isSuccess = true,
+ urlPath = urlPath,
+ filePath = filePath
+ )
+ )
+ it.resumeWithActive(res)
+ }
+ }
+ }
+
+ TransferState.FAILED, TransferState.CANCELED -> {
+ it.resumeWithActive(createNormalErrorFileData(filePath))
+ }
+
+ else -> {
+
+ }
+ }
+ }
+
+ override fun onProgressChanged(id: Int, bytesCurrent: Long, bytesTotal: Long) {
+ Timber.d("oss上传 - AmazonS3 onProgressChanged - bytesTotal:${bytesTotal} - bytesCurrent:${bytesCurrent}")
+ }
+
+ override fun onError(id: Int, ex: Exception?) {
+ Timber.d("oss上传 - AmazonS3 onError:${ex?.localizedMessage} - id:$id")
+ }
+ }
+
+ when {
+ filePath.isNotEmpty() -> {
+ val file = File(filePath)
+ if (!file.exists()) {
+ it.cancel()
+ }
+ Timber.d("oss上传 - 上传文件大小 ${file.length() / 1024}")
+ transferUtility.upload(stsToken.bucket, path, file, obj)
+ .setTransferListener(transferListener)
+ }
+ }
+ }
+
+ suspend fun checkText(content: String): Response = ossRepository.checkText(content)
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMAIInMessage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMAIInMessage.kt
new file mode 100644
index 0000000..7c62f0d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMAIInMessage.kt
@@ -0,0 +1,12 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.entity.imbean.voice.IMVoice
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class IMAIInMessage(
+ override var message: V2NIMMessage?,
+ val imVoice: IMVoice
+) : IMMessageWrapper(type = IN_TEXT_TYPE)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMBaseInfoMessage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMBaseInfoMessage.kt
new file mode 100644
index 0000000..e512bd0
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMBaseInfoMessage.kt
@@ -0,0 +1,10 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.entity.response.Character
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class IMBaseInfoMessage(
+ val character: Character?
+) : IMMessageWrapper(type = BASE_INFO)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMCallMessage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMCallMessage.kt
new file mode 100644
index 0000000..f13ea8b
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMCallMessage.kt
@@ -0,0 +1,12 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.entity.imbean.raw.CustomCallData
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class IMCallMessage(
+ override var message: V2NIMMessage?,
+ val call: CustomCallData?
+) : IMMessageWrapper(type = OUT_CALL_TYPE)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMGiftMessage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMGiftMessage.kt
new file mode 100644
index 0000000..d57d1bc
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMGiftMessage.kt
@@ -0,0 +1,12 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.entity.imbean.raw.CustomGiftData
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class IMGiftMessage(
+ override var message: V2NIMMessage?,
+ val gift: CustomGiftData?
+) : IMMessageWrapper(type = OUT_GIFT_TYPE)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMInImageMessage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMInImageMessage.kt
new file mode 100644
index 0000000..22f30fb
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMInImageMessage.kt
@@ -0,0 +1,12 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class IMInImageMessage(
+ override var message: V2NIMMessage?,
+ val albumData: CustomAlbumData?
+) : IMMessageWrapper(type = IN_IMAGE_TYPE)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMLevelMessage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMLevelMessage.kt
new file mode 100644
index 0000000..1721863
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMLevelMessage.kt
@@ -0,0 +1,12 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.entity.imbean.raw.CustomLevelChangeData
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class IMLevelMessage(
+ override var message: V2NIMMessage?,
+ val level: CustomLevelChangeData?
+) : IMMessageWrapper(type = HEART_BEAT_CHANGED_TYPE)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMMessageWrapper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMMessageWrapper.kt
new file mode 100644
index 0000000..daf1e60
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMMessageWrapper.kt
@@ -0,0 +1,44 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.manager.nim.FetchResult
+import com.remax.visualnovel.manager.nim.LoadStatus
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+
+
+/**
+ * Created by HJW on 2020/9/28
+ */
+open class IMMessageWrapper(
+ open var message: V2NIMMessage? = null,
+ var type: Int = OUT_TEXT_TYPE,
+) {
+ /**
+ * 表示该消息后是否要加一条AI输入中的消息
+ */
+ var aiIsSending: Boolean = true
+
+ var fetchType: FetchResult.FetchType = FetchResult.FetchType.Init
+ var loadStatus: LoadStatus = LoadStatus.Success
+
+ companion object {
+ const val BASE_INFO = 0
+
+ // ai回复中
+ const val INPUT_ING = 1
+
+ const val OUT_TEXT_TYPE = 2
+ const val IN_TEXT_TYPE = 3
+
+ const val OUT_IMAGE_TYPE = 4
+ const val IN_IMAGE_TYPE = 5
+
+ const val OUT_GIFT_TYPE = 6
+
+ const val OUT_CALL_TYPE = 7
+
+ /**
+ * 心动等级升级/降级
+ */
+ const val HEART_BEAT_CHANGED_TYPE = 8
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMOutImageMessage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMOutImageMessage.kt
new file mode 100644
index 0000000..dc8e199
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/IMOutImageMessage.kt
@@ -0,0 +1,12 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.remax.visualnovel.entity.imbean.raw.CustomRawData
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class IMOutImageMessage(
+ override var message: V2NIMMessage?,
+ val customRawData: CustomRawData?
+) : IMMessageWrapper(type = OUT_IMAGE_TYPE)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/RecentContactWrapper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/RecentContactWrapper.kt
new file mode 100644
index 0000000..cb34887
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/RecentContactWrapper.kt
@@ -0,0 +1,19 @@
+package com.remax.visualnovel.entity.imbean
+
+import com.netease.nimlib.sdk.v2.conversation.model.V2NIMConversation
+import com.netease.nimlib.sdk.v2.utils.V2NIMConversationIdUtil
+
+/**
+ * Created by HJW on 2020/10/9
+ */
+data class RecentContactWrapper(
+ var recentContact: V2NIMConversation
+) {
+ val aiId: String
+ get() {
+ val targetId = V2NIMConversationIdUtil.conversationTargetId(recentContact.conversationId)
+
+ return targetId.substring(0, targetId.indexOf("@"))
+ }
+}
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/voice/IMVoice.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/voice/IMVoice.kt
new file mode 100644
index 0000000..6190590
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/voice/IMVoice.kt
@@ -0,0 +1,27 @@
+package com.remax.visualnovel.entity.imbean.voice
+
+import com.remax.visualnovel.entity.response.base.BaseVoice
+
+/**
+ * Created by HJW on 2025/8/28
+ */
+data class IMVoice(
+ val code: String,
+ var filePath: String? = null,
+ var url: String? = null,
+ var autoPlay: Boolean = false
+) : BaseVoice() {
+
+ override fun id(): String {
+ return code
+ }
+
+ override fun url(): String {
+ return url ?: ""
+ }
+
+ override fun filePathName(): String {
+ return filePath ?: ""
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIGenerate.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIGenerate.kt
new file mode 100644
index 0000000..3af0402
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIGenerate.kt
@@ -0,0 +1,58 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/7/29
+ */
+data class AIGenerate(
+ val nickname: String? = null,
+ val birthday: String? = null,
+ val sex: Int? = null,
+ val introduction: String? = null,
+ val characterCode: String? = null,
+ val tagCode: String? = null,
+ val roleCode: String? = null,
+ val content: String? = null,
+ val ptType: String? = null,
+ val figure: String? = null, //ai的人物基础信息(背景、性格、身份)【生成个人简介时使用】
+ val dialogue: String? = null, // ai对话风格(角色的聊天方式、对话语气)【生成个人简介时使用】
+) {
+ companion object {
+ //AI一键生成人物基础信息 AI自行创作
+ const val GEN_PROFILE_BY_NON = "GEN_PROFILE_BY_NON"
+
+ //AI一键生成人物基础信息 AI根据用户输入进行创作
+ const val GEN_PROFILE_BY_CONTENT = "GEN_PROFILE_BY_CONTENT"
+
+ //AI一键生成对话风格 AI自行创作
+ const val GEN_DIALOG_STYLE_BY_NON = "GEN_DIALOG_STYLE_BY_NON"
+
+ //AI一键生成对话风格 AI根据用户输入进行创作
+ const val GEN_DIALOG_STYLE_BY_CONTENT = "GEN_DIALOG_STYLE_BY_CONTENT"
+
+ //AI一键生成开场白 AI自行创作
+ const val GEN_PROLOGUE_BY_NON = "GEN_PROLOGUE_BY_NON"
+
+ //AI一键生成开场白 AI根据用户输入进行创作
+ const val GEN_PROLOGUE_BY_CONTENT = "GEN_PROLOGUE_BY_CONTENT"
+
+ //AI一键生成人物简介 AI总结
+ const val GEN_INTRODUCTION = "GEN_INTRODUCTION"
+
+ //图生文-参考图生成prompt
+ const val GEN_AI_IMAGE_DESC = "GEN_AI_IMAGE_DESC_BY_NON"
+
+ //编辑AI或相册图片生成时,合并新老形象描述
+ const val MERGE_NEW_OLD_IMAGE_DESC = "MERGE_NEW_OLD_IMAGE_DESC"
+
+ //文生图-生成6组不同的prompt
+ const val TEXT_TO_IMAGE_PROMPT_V2 = "TEXT_TO_IMAGE_PROMPT_V2"
+
+ //图生文-参考图生成prompt
+ const val IMAGE_REFERENCE = "IMAGE_REFERENCE"
+
+ //文生图-生成6组不同的prompt
+ const val TXT_TO_IMAGE_PROMPT = "TXT_TO_IMAGE_PROMPT"
+
+ }
+}
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIGenerateImage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIGenerateImage.kt
new file mode 100644
index 0000000..c0c3fca
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIGenerateImage.kt
@@ -0,0 +1,28 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/7/30
+ */
+data class AIGenerateImage(
+ val type: String? = null,
+ val aiId: String? = null,
+ val imageStylePrompt: String? = null,
+ val content: String? = null,
+ val imageReferenceUrl: String? = null,
+ val batchNo: String? = null,
+ var hl: Boolean? = null,
+) {
+ companion object {
+ //创建ai形象
+ const val CREATE_AI_IMAGE = "CREATE_AI_IMAGE"
+
+ //编辑ai形象
+ const val EDIT_AI_IMAGE = "EDIT_AI_IMAGE"
+
+ //相册
+ const val ALBUM = "ALBUM"
+
+ //背景
+ const val BACKGROUND = "BACKGROUND"
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIHeadImgRequest.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIHeadImgRequest.kt
new file mode 100644
index 0000000..78e33db
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIHeadImgRequest.kt
@@ -0,0 +1,9 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/9/16
+ */
+data class AIHeadImgRequest (
+ val aiId:String,
+ val userHead:String?,
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIIDRequest.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIIDRequest.kt
new file mode 100644
index 0000000..339c87d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIIDRequest.kt
@@ -0,0 +1,8 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/24
+ */
+open class AIIDRequest(
+ open val aiId: String
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIIsShowDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIIsShowDTO.kt
new file mode 100644
index 0000000..5eb57af
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIIsShowDTO.kt
@@ -0,0 +1,9 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/22
+ */
+data class AIIsShowDTO(
+ val aiId: String,
+ val isShow: Int //默认关闭 0:关闭 1:打开
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIListRequest.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIListRequest.kt
new file mode 100644
index 0000000..f130006
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AIListRequest.kt
@@ -0,0 +1,8 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/24
+ */
+data class AIListRequest(
+ val aiIdList: List
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AlbumDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AlbumDTO.kt
new file mode 100644
index 0000000..e893b07
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/AlbumDTO.kt
@@ -0,0 +1,21 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2020/11/19
+ */
+data class AlbumDTO(
+ val albumId: Long? = null,
+ val likedStatus: String? = null,
+ val liked: Boolean? = null,
+ val userId: String? = null,
+ var height: Int? = null,
+ var url: String? = null,
+ var width: Int? = null,
+ var unlockPrice: Long? = null,
+ val aiId: String? = null,
+)
+
+data class AlbumCreate(
+ val aiId: String?,
+ val images: List
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/CardRequest.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/CardRequest.kt
new file mode 100644
index 0000000..29707f1
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/CardRequest.kt
@@ -0,0 +1,9 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/9/10
+ */
+data class CardRequest(
+ val aiId: String?,
+ val lk: Boolean
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeOrderDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeOrderDTO.kt
new file mode 100644
index 0000000..fa2e843
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeOrderDTO.kt
@@ -0,0 +1,10 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2022/7/19
+ */
+data class ChargeOrderDTO(
+ val chargeAmount: Long,
+ val productId: String,
+ val version: Int = 1,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeProduct.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeProduct.kt
new file mode 100644
index 0000000..04a1a3f
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeProduct.kt
@@ -0,0 +1,35 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2022/7/19
+ */
+data class ChargeProduct(
+ val id: Int,
+ var selected: Boolean = false,
+ var hot: Boolean?,
+ var localCurrencyCode: String = "USD",
+ var local: String?=null,
+ val payAmount: String,
+
+ /**
+ * 充值到账的BUFF金额
+ */
+ val chargeAmount: Long,
+
+ /**
+ * 赠送的总金额
+ */
+ val giftAmount: Long,
+
+ /**
+ * 商品ID
+ */
+ val productId: String
+)
+
+
+data class ChargeProductInfo(
+ val productList: List,
+ val countdown: Long? = null
+)
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeProductDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeProductDTO.kt
new file mode 100644
index 0000000..f09f4fa
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChargeProductDTO.kt
@@ -0,0 +1,11 @@
+package com.remax.visualnovel.entity.request
+
+import com.remax.visualnovel.constant.AppConstant
+
+/**
+ * Created by HJW on 2022/7/19
+ */
+data class ChargeProductDTO(
+ val platform: String = AppConstant.ANDROID,
+ val version: Int = 1
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChatAlbum.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChatAlbum.kt
new file mode 100644
index 0000000..7c27d21
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChatAlbum.kt
@@ -0,0 +1,11 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/29
+ */
+data class ChatAlbum(
+ val aiId: String,
+ val albumId: Long?,
+ val unlockPrice: Long?,
+ val messageServerId: String? = null
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChatSetting.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChatSetting.kt
new file mode 100644
index 0000000..d2ca539
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ChatSetting.kt
@@ -0,0 +1,21 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/23
+ */
+data class ChatSetting(
+ val aiId: String?,
+ /**
+ * 修改聊天气泡/模型
+ */
+ val code: String? = null,
+ /**
+ * 修改聊天背景
+ */
+ val backgroundId: Int? = null,
+ val backgroundImg: String? = null,
+ /**
+ * 自动播放语音开关
+ */
+ val isAutoPlayVoice: Boolean?= null,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ClassificationRequest.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ClassificationRequest.kt
new file mode 100644
index 0000000..6f308c0
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ClassificationRequest.kt
@@ -0,0 +1,46 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/9/9
+ */
+data class ClassificationRequest(
+ /**
+ * 情感性格code
+ */
+ var characterCodeList: List?=null,
+
+ /**
+ * 需要排除的aiId列表
+ */
+ var exList: MutableList = mutableListOf(),
+
+ /**
+ * 页码
+ */
+ var pn: Int = 1,
+
+ /**
+ * 年龄
+ */
+ var age: String? = null,
+
+ /**
+ * 性别:单选
+ */
+ var sex: Int? = null,
+
+ /**
+ * 角色code列表
+ */
+ var roleCodeList: List?=null,
+
+ /**
+ * 标签code列表
+ */
+ var tagCodeList: List?=null,
+
+ /**
+ * 每页大小
+ */
+ val ps: Int = PageQuery.DEFAULT_PAGE_SIZE,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/Gift.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/Gift.kt
new file mode 100644
index 0000000..69881f2
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/Gift.kt
@@ -0,0 +1,22 @@
+package com.remax.visualnovel.entity.request
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Created by HJW on 2025/8/18
+ */
+
+@Parcelize
+data class Gift(
+ val id: Int,
+ val name: String,
+ val desc: String?,
+ val icon: String?,
+ val startVal: Double?,
+ val heartbeatLevel: String?, // 发送该礼物需要的心动等级
+ val price: Long,
+ var getNum: Int?,
+ var isMemberGift: Boolean? = null,
+ var select: Boolean = false
+) : Parcelable
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/HeartbeatBuy.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/HeartbeatBuy.kt
new file mode 100644
index 0000000..642a58c
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/HeartbeatBuy.kt
@@ -0,0 +1,9 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/9/22
+ */
+data class HeartbeatBuy(
+ val aiId: String?,
+ val heartbeatVal: Double?,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/HeartbeatRelation.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/HeartbeatRelation.kt
new file mode 100644
index 0000000..68aa43a
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/HeartbeatRelation.kt
@@ -0,0 +1,30 @@
+package com.remax.visualnovel.entity.request
+
+import android.os.Parcelable
+import com.remax.visualnovel.entity.response.HeartbeatLevelEnum
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Created by HJW on 2025/8/20
+ */
+@Parcelize
+data class HeartbeatRelation(
+ val aiHeadImg: String, //AI头像
+ val userHeadImg: String, // 用户头像
+ var heartbeatLevel: String?, //心动等级类型
+ var heartbeatLevelName: String?, //心动等级名称
+ var heartbeatLevelNum: Int?, //心动等级
+ val dayCount: Int, //相识天数
+ val price: Long?, //心动值单价
+ var heartbeatVal: Double?, //心动值
+ val subtractHeartbeatVal: Double?, //已扣减心动值
+ var heartbeatScore: Float?, //心动分
+ var isShow: Boolean?,
+ var aiId: String? = null
+) : Parcelable {
+
+ val currHeartbeatEnum: HeartbeatLevelEnum?
+ get() = HeartbeatLevelEnum.entries.find { it.levelName == heartbeatLevel }
+
+}
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ImgCheckDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ImgCheckDTO.kt
new file mode 100644
index 0000000..cb604f0
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ImgCheckDTO.kt
@@ -0,0 +1,9 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2020/10/21
+ */
+data class ImgCheckDTO(
+ val bizTypeEnum: String,
+ val fileFullPath: String,
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PageQuery.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PageQuery.java
new file mode 100644
index 0000000..3fe9dc3
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PageQuery.java
@@ -0,0 +1,32 @@
+package com.remax.visualnovel.entity.request;
+
+/**
+ * Created by Eric on 2020/9/9
+ */
+public class PageQuery {
+
+ public static final int DEFAULT_PAGE_SIZE = 20;
+
+ public Page page = new Page();
+
+ public PageQuery(int pn) {
+ this.page.pn = pn;
+ }
+
+ public static class Page {
+
+ public int pn = 1;
+
+ public int ps = DEFAULT_PAGE_SIZE;
+
+ public Page() {
+
+ }
+
+ public Page(int pn) {
+ this.pn = pn;
+ }
+
+
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/QueryAlbumDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/QueryAlbumDTO.kt
new file mode 100644
index 0000000..15ee706
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/QueryAlbumDTO.kt
@@ -0,0 +1,10 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2023/8/31
+ */
+data class QueryAlbumDTO(
+ val aiId: String,
+ val userId:String?,
+ val page: PageQuery.Page
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/RTCRequest.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/RTCRequest.kt
new file mode 100644
index 0000000..3c3d5e2
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/RTCRequest.kt
@@ -0,0 +1,24 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/25
+ */
+data class RTCRequest(
+ val roomId: String,
+ /**
+ * 操作类型 开启通话:START,打断:INTERRUPT,结束通话:STOP
+ */
+ val optType: String? = null,
+ val duration: Long? = null,
+ /**
+ * 任务id
+ */
+ val taskId: String? = null,
+ val targetId: String? = null
+) : AIIDRequest(targetId ?: "") {
+ companion object {
+ const val START = "START"
+ const val INTERRUPT = "INTERRUPT"
+ const val STOP = "STOP"
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/S3TypeDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/S3TypeDTO.kt
new file mode 100644
index 0000000..e17671c
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/S3TypeDTO.kt
@@ -0,0 +1,18 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2022/3/7
+ */
+data class S3TypeDTO(
+ val bizTypeEnum: String,
+ val suffix: String
+) {
+ companion object {
+ const val ROLE = "ROLE"
+ const val ALBUM = "ALBUM"
+ const val HEAD_IMAGE = "HEAD_IMAGE"
+ const val SOUND = "SOUND"
+ const val SOUND_PATH = "SOUND_PATH"
+ const val IM_IMAGE = "IM_IMG"
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SearchPage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SearchPage.kt
new file mode 100644
index 0000000..4c169a2
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SearchPage.kt
@@ -0,0 +1,10 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/27
+ */
+data class SearchPage(
+ val page: PageQuery.Page,
+ val nickname: String? = null,
+ val type: String? = null,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SendGift.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SendGift.kt
new file mode 100644
index 0000000..5067025
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SendGift.kt
@@ -0,0 +1,16 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/23
+ */
+data class SendGift(
+ val aiId: String,
+ val num: Int,
+ val giftId: Int,
+ val scene: String = IM,
+) {
+ companion object {
+ const val IM = "IM"
+ const val HOME = "HOME"
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleContentDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleContentDTO.kt
new file mode 100644
index 0000000..dcc05ae
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleContentDTO.kt
@@ -0,0 +1,5 @@
+package com.remax.visualnovel.entity.request
+
+data class SimpleContentDTO(
+ val content: String
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleCountDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleCountDTO.kt
new file mode 100644
index 0000000..5be2aed
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleCountDTO.kt
@@ -0,0 +1,8 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/9/17
+ */
+data class SimpleCountDTO(
+ val count: Int
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleDataDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleDataDTO.kt
new file mode 100644
index 0000000..57fd206
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleDataDTO.kt
@@ -0,0 +1,10 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/21
+ */
+data class SimpleDataDTO(
+ var aiId: String? = null,
+ val data: String? = null,
+ val url: String? = null,
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleTypeDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleTypeDTO.kt
new file mode 100644
index 0000000..9b68f54
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SimpleTypeDTO.kt
@@ -0,0 +1,5 @@
+package com.remax.visualnovel.entity.request
+
+data class SimpleTypeDTO(
+ val type: String
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SubPriceDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SubPriceDTO.kt
new file mode 100644
index 0000000..48be7fc
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/SubPriceDTO.kt
@@ -0,0 +1,11 @@
+package com.remax.visualnovel.entity.request
+
+import com.remax.visualnovel.constant.AppConstant
+
+/**
+ * Created by HJW on 2021/8/27
+ */
+data class SubPriceDTO(
+ val platform: String = AppConstant.ANDROID,
+ val version: String = "2"
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ValidateTransactionDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ValidateTransactionDTO.kt
new file mode 100644
index 0000000..924a191
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/ValidateTransactionDTO.kt
@@ -0,0 +1,15 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2022/6/28
+ */
+data class ValidateTransactionDTO(
+ val productId: String?,
+ /**
+ * purchase token
+ */
+ val receipt: String,
+ var orderId: String? = null,
+ var currency: String? = null,
+ var price: Double? = null,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/VoiceTTS.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/VoiceTTS.kt
new file mode 100644
index 0000000..95ee9f7
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/VoiceTTS.kt
@@ -0,0 +1,32 @@
+package com.remax.visualnovel.entity.request
+
+/**
+ * Created by HJW on 2025/8/28
+ */
+data class VoiceTTS(
+
+ var aiId: String? = null,
+ /**
+ * 音量(Volume)。值范围为 [-12, 12]。默认:0
+ */
+ var pitchRate: String = DEFAULT,
+
+ /**
+ * 语速,范围 [-50,100],100代表2.0倍速,-50代表0.5倍速
+ */
+ var speechRate: String = DEFAULT,
+
+ /**
+ * 文本内容
+ */
+ var text: String?,
+
+ /**
+ * 语音类型
+ */
+ var voiceType: String?
+) {
+ companion object {
+ const val DEFAULT = "0"
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AccountBuffBill.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AccountBuffBill.kt
new file mode 100644
index 0000000..665abcc
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AccountBuffBill.kt
@@ -0,0 +1,38 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/9/12
+ */
+
+data class Transaction(
+ val pageList: Pageable
+)
+
+data class TransactionGift(
+ val giftId: Int? = null
+)
+
+data class AccountBuffBill(
+ val amount: Long,
+ val bizNum: String,
+ val bizType: String,
+ val buffType: String,
+ val item: String,
+ val inOrOut: String? = null,
+ val time: Long,
+ val extend: String? = null,
+ val toWithdrawableIncomeTime: Long,
+ val tradeNo: String,
+) {
+
+ val isIn: Boolean
+ get() = inOrOut == IN
+
+ companion object {
+ const val BALANCE = "BALANCE"
+ const val INCOME = "INCOME"
+
+ const val IN = "IN"
+ const val OUT = "OUT"
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AdvertiseOutput.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AdvertiseOutput.kt
new file mode 100644
index 0000000..a2ac346
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AdvertiseOutput.kt
@@ -0,0 +1,66 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/9/8
+ */
+data class AdvertiseOutput(
+ /**
+ * 使用端点(WEB/ANDROID/IOS)
+ * endpoint
+ */
+ val endpoint: String? = null,
+
+ /**
+ * 扩展字段
+ * ext
+ */
+ val ext: String? = null,
+
+ /**
+ * 广告配图
+ * icon
+ */
+ val icon: String? = null,
+
+ /**
+ * 是否弹窗(1.是,0.否)
+ * is_global
+ */
+ val isGlobal: Long? = null,
+
+ /**
+ * 跳转连接
+ * jump_link
+ */
+ val jumpLink: String? = null,
+
+ /**
+ * 广告名称
+ * name
+ */
+ val name: String? = null,
+
+ /**
+ * 展示结束时间
+ * show_end_time
+ */
+ val showEndTime: String? = null,
+
+ /**
+ * 展示开始时间
+ * show_start_time
+ */
+ val showStartTime: String? = null,
+
+ /**
+ * 排序
+ * sort
+ */
+ val sort: Long? = null
+) {
+ var type: String? = null
+
+ companion object {
+ const val SIGN = "SIGN"
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AlbumCreateCountOutput.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AlbumCreateCountOutput.kt
new file mode 100644
index 0000000..28ea8be
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AlbumCreateCountOutput.kt
@@ -0,0 +1,39 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/9/16
+ */
+data class AlbumCreateCountOutput(
+ /**
+ * 购买创作次数
+ */
+ val buyNum: Int,
+ val usedBuyNum: Int,
+
+ /**
+ * 免费创作次数
+ */
+ val freeNum: Int,
+ val usedFreeNum: Int,
+
+ /**
+ * 会员赠送创作次数
+ */
+ val memberNum: Int,
+ val usedMemberNum: Int
+) {
+ val hasFree: Boolean
+ get() = usedFreeNum < freeNum
+
+ val hasVipTime: Boolean
+ get() = usedMemberNum < memberNum
+
+ val hasNum: Boolean
+ get() = usedBuyNum < buyNum
+
+ val canCreate: Boolean
+ get() = hasFree || hasVipTime || hasNum
+
+ val canUseCount: Int
+ get() = (buyNum - usedBuyNum) + (memberNum - usedMemberNum) + (freeNum - usedFreeNum)
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/BucketBean.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/BucketBean.kt
new file mode 100644
index 0000000..5e9f7a1
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/BucketBean.kt
@@ -0,0 +1,19 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2020/10/20
+ */
+data class BucketBean(
+ val expiration: String,
+ val region: String,
+ val requestId: String,
+ val accessKeyId: String,
+ val accessKeySecret: String,
+ val bucket: String,
+ val endPoint: String,
+ val path: String,
+ val urlPath: String,
+ val securityToken: String,
+ var tempTime: Long,
+ var type: String
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt
index 8aae888..6b3d6a0 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt
@@ -1,6 +1,7 @@
package com.remax.visualnovel.entity.response
import android.os.Parcelable
+import com.remax.visualnovel.entity.request.HeartbeatRelation
import com.remax.visualnovel.extension.calculateAge
import com.remax.visualnovel.extension.getNimAccountId
import kotlinx.parcelize.Parcelize
@@ -49,8 +50,8 @@ data class Character(
var isHaveChatted: Boolean? = null, //是否聊过天
var isDelChatted: Boolean? = null, //是否删除过会话
var isAutoPlayVoice: Int? = null, //自动播放语音开关 1:开 0:关
- //var aiUserHeartbeatRelation: HeartbeatRelation? = null,
- //var chatBubble: ChatBubble? = null,
+ var aiUserHeartbeatRelation: HeartbeatRelation? = null,
+ var chatBubble: ChatBubble? = null,
//排行榜使用
var rankNo: Int? = null,
@@ -65,7 +66,7 @@ data class Character(
var role: String? = null,
var tag: String? = null,
var isSecret: Boolean? = null,
- //var albumList: List? = null,
+ var albumList: List? = null,
) : Parcelable {
companion object {
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChargeOrder.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChargeOrder.java
new file mode 100644
index 0000000..59c6074
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChargeOrder.java
@@ -0,0 +1,6 @@
+package com.remax.visualnovel.entity.response;
+
+
+public class ChargeOrder {
+ public String tradeNo;
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBubble.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBubble.kt
new file mode 100644
index 0000000..9fd0233
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBubble.kt
@@ -0,0 +1,52 @@
+package com.remax.visualnovel.entity.response
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Created by HJW on 2025/8/18
+ */
+@Parcelize
+data class ChatBubble(
+ /**
+ * code
+ */
+ val code: String,
+
+ /**
+ * id
+ */
+ val id: Long,
+
+ /**
+ * 图片url
+ */
+ val imgUrl: String?,
+
+ /**
+ * 当前用户是否解锁 false:未解锁,true:解锁
+ */
+ val isUnlock: Boolean? = null,
+
+ /**
+ * 名称
+ */
+ val name: String,
+
+ /**
+ * 解锁心动等级 类型为HEARTBEAT_LEVEL时才有用
+ */
+ val unlockHeartbeatLevel: String? = null,
+
+ /**
+ * 解锁类型 MEMBER:会员 HEARTBEAT_LEVEL:心动等级
+ */
+ val unlockType: String? = null,
+ var isDefault: Boolean,
+ var select: Boolean = false
+) : Parcelable {
+ companion object {
+ const val MEMBER = "MEMBER"
+ const val HEARTBEAT_LEVEL = "HEARTBEAT_LEVEL"
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatModel.kt
new file mode 100644
index 0000000..01f2405
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatModel.kt
@@ -0,0 +1,42 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/8/18
+ */
+data class ChatModel(
+ /**
+ * 对话模型code
+ */
+ val code: String? = null,
+
+ /**
+ * 对话模型描述
+ */
+ val description: String? = null,
+
+ /**
+ * 对话模型名称
+ */
+ val name: String? = null,
+
+ /**
+ * 问号图标内容
+ */
+ val questionMark: String? = null,
+
+ /**
+ * 文本价格
+ */
+ val textPrice: Long? = null,
+
+ /**
+ * 语音聊天价格
+ */
+ val voiceChatPrice: Long? = null,
+
+ /**
+ * 发送和听语音价格
+ */
+ val voicePrice: Long? = null,
+
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatSet.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatSet.kt
new file mode 100644
index 0000000..b479aaa
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatSet.kt
@@ -0,0 +1,66 @@
+package com.remax.visualnovel.entity.response
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Created by HJW on 2025/8/23
+ */
+@Parcelize
+data class ChatSet(
+ /**
+ * ai的Id
+ */
+ val aiId: String,
+
+ /**
+ * 聊天背景图片
+ */
+ val backgroundImg: String?,
+ val isDefaultBackground: Boolean?,
+
+ /**
+ * 出生日期
+ */
+ var birthday: Long?,
+
+ /**
+ * 聊天气泡code
+ */
+ val bubbleCode: String?,
+
+ /**
+ * 聊天气泡名称
+ */
+ val bubbleName: String?,
+
+ /**
+ * 自动播放语音开关 1:开 0:关
+ */
+ val isAutoPlayVoice: Int?,
+
+ /**
+ * 对话模型code
+ */
+ var modelCode: String?,
+
+ /**
+ * 对话模型名称
+ */
+ var modelName: String?,
+
+ /**
+ * 昵称
+ */
+ var nickname: String?,
+
+ /**
+ * 0,男;1,女;2,自定义
+ */
+ val sex: Int?,
+
+ /**
+ * 我是谁
+ */
+ var whoAmI: String?
+): Parcelable
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ContentRes.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ContentRes.kt
new file mode 100644
index 0000000..a4d335a
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ContentRes.kt
@@ -0,0 +1,8 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/7/29
+ */
+open class ContentRes(
+ val content: String?
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ExploreInfo.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ExploreInfo.kt
new file mode 100644
index 0000000..eeafb49
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ExploreInfo.kt
@@ -0,0 +1,26 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/9/8
+ */
+data class ExploreInfo(
+ /**
+ * AI总心动值榜单top3
+ */
+ val aiChatRankTop3List: List? = null,
+
+ /**
+ * AI总心动值榜单top3
+ */
+ val aiGiftRankTop3List: List? = null,
+
+ /**
+ * AI总心动值榜单top3
+ */
+ val aiHeartbeatRankTop3List: List? = null,
+
+ /**
+ * 广告列表
+ */
+ val outputList: List? = null
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Friends.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Friends.kt
new file mode 100644
index 0000000..3fc0dc8
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Friends.kt
@@ -0,0 +1,64 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/8/14
+ */
+data class Friends(
+ val aiId: String,
+ /**
+ * 出生日期
+ */
+ val birthday: Long,
+
+ /**
+ * 性格名称
+ */
+ val characterName: String,
+
+ /**
+ * 头像
+ */
+ val headImg: String,
+
+ /**
+ * 心动等级
+ */
+ val heartbeatLevel: String,
+
+ /**
+ * 心动等级数字
+ */
+ val heartbeatLevelNum: Int,
+
+ /**
+ * 心动值
+ */
+ val heartbeatVal: Double,
+
+ /**
+ * 昵称
+ */
+ val nickname: String,
+
+ /**
+ * 角色名称
+ */
+ val roleName: String,
+
+ /**
+ * 0,男;1,女;2,自定义
+ */
+ val sex: Int,
+
+ /**
+ * 标签名称
+ */
+ val tagName: String,
+
+ /**
+ * ai所属用户id
+ */
+ val userId: String,
+
+ val isShow: Boolean
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/HeartbeatLevel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/HeartbeatLevel.kt
new file mode 100644
index 0000000..b043c6d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/HeartbeatLevel.kt
@@ -0,0 +1,67 @@
+package com.remax.visualnovel.entity.response
+
+import androidx.annotation.StringRes
+import com.remax.visualnovel.R
+
+/**
+ * Created by HJW on 2025/8/21
+ */
+data class HeartbeatLevel(
+ /**
+ * 心动等级code
+ */
+ val code: String,
+ /**
+ * 心动等级图标
+ */
+ val imgUrl: String,
+ /**
+ * 用户是否解锁
+ */
+ val isUnlock: Boolean,
+ /**
+ * 心动等级名称
+ */
+ val name: String,
+ val startVal: Double
+)
+
+enum class HeartbeatLevelEnum(
+ val levelName: String,
+ val level: Int,
+ val levelContent: String,
+ val startVal: Double,
+ @StringRes val tagName: Int
+) {
+
+ // 初识勋章
+ LEVEL_1("LEVEL_1", 1, "Lv.1", 0.50, R.string.meet),
+
+ // 发图功能
+ LEVEL_2("LEVEL_2", 2, "Lv.2", 3.50, R.string.meet),
+
+ // 朋友勋章
+ LEVEL_3("LEVEL_3", 3, "Lv.3", 12.00, R.string.friend),
+
+ // 语音通话
+ LEVEL_4("LEVEL_4", 4, "Lv.4", 30.00, R.string.friend),
+
+ // 暧昧勋章
+ LEVEL_5("LEVEL_5", 5, "Lv.5", 90.00, R.string.flirting),
+
+ // 专属礼物
+ LEVEL_6("LEVEL_6", 6, "Lv.6", 270.00, R.string.flirting),
+
+ // 恋人勋章
+ LEVEL_7("LEVEL_7", 7, "Lv.7", 540.00, R.string.couple),
+
+ // 专属聊天气泡
+ LEVEL_8("LEVEL_8", 8, "Lv.8", 990.00, R.string.couple),
+
+ // 结婚勋章
+ LEVEL_9("LEVEL_9", 9, "Lv.9", 1778.00, R.string.married),
+
+ // 自定义形象
+ LEVEL_10("LEVEL_10", 10, "Lv.10", 2957.00, R.string.married),
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/HeartbeatLevelOutput.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/HeartbeatLevelOutput.kt
new file mode 100644
index 0000000..20559fa
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/HeartbeatLevelOutput.kt
@@ -0,0 +1,18 @@
+package com.remax.visualnovel.entity.response
+
+import com.remax.visualnovel.entity.request.HeartbeatRelation
+
+/**
+ * Created by HJW on 2025/8/21
+ */
+data class HeartbeatLevelOutput(
+ /**
+ * 当前用户与AI的心动关系
+ */
+ val aiUserHeartbeatRelation: HeartbeatRelation,
+
+ /**
+ * 心动等级字典列表
+ */
+ val heartbeatLeveLDictList: List
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MeetSdOutput.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MeetSdOutput.kt
new file mode 100644
index 0000000..a1dd08f
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MeetSdOutput.kt
@@ -0,0 +1,15 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/9/10
+ */
+data class MeetSdOutput(
+ /**
+ * 是否能够调用绑定
+ */
+ val bd: Boolean? = null,
+ /**
+ * 是否能够调用爱慕者推荐
+ */
+ val rc: Boolean? = null
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MessageListOutput.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MessageListOutput.kt
new file mode 100644
index 0000000..b2de65e
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MessageListOutput.kt
@@ -0,0 +1,59 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/8/26
+ */
+data class MessageListOutput(
+
+ /**
+ * 导致消息发送的业务ID
+ */
+ val bizId: String,
+ /**
+ * 消息内容
+ */
+ val content: String,
+
+ /**
+ * 消息时间
+ */
+ val createTime: Long,
+
+ /**
+ * 消息扩展内容
+ */
+ val extras: String? = null,
+
+ /**
+ * 消息ID
+ */
+ val id: Long? = null,
+
+ /**
+ * 发送人用户ID
+ */
+ val sendUserId: Long? = null,
+
+ /**
+ * 消息状态(0未读、1已读)
+ */
+ val status: Int,
+
+ /**
+ * 消息标题
+ */
+ val title: String,
+
+ /**
+ * 消息类型
+ */
+ val type: Int
+) {
+ companion object {
+ const val UNREAD = 0
+ }
+
+ data class VIPRenewExtra(
+ val expTime: Long
+ )
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MessageStatOutput.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MessageStatOutput.kt
new file mode 100644
index 0000000..5c4fdf1
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/MessageStatOutput.kt
@@ -0,0 +1,21 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/8/26
+ */
+data class MessageStatOutput(
+ /**
+ * 最新未读消息内容
+ */
+ val latestContent: String,
+
+ /**
+ * 最新未读消息时间
+ */
+ val latestTime: Long,
+
+ /**
+ * 未读数量
+ */
+ val unRead: Int
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/NimBean.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/NimBean.kt
new file mode 100644
index 0000000..b517ef4
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/NimBean.kt
@@ -0,0 +1,10 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/8/19
+ */
+data class NimBean(
+ val accountId: String,
+ val token: String,
+ val appKey: String,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Pageable.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Pageable.java
new file mode 100644
index 0000000..5151032
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Pageable.java
@@ -0,0 +1,50 @@
+package com.remax.visualnovel.entity.response;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Created by Eric on 2020/9/9
+ */
+public class Pageable {
+
+ public static final int DEFAULT_PAGE_SIZE = 20;
+
+ public List datas = new ArrayList<>();
+
+ /**
+ * 每页条数
+ */
+ public int ps = DEFAULT_PAGE_SIZE;
+
+ /**
+ * 页码
+ */
+ public int pn = 1;
+
+ /**
+ * 总数
+ */
+ public int tc;
+
+ /**
+ * 订单搜索总价
+ */
+ public long amount;
+
+ /**
+ * 是否还有更多数据?
+ *
+ * @return
+ */
+ public boolean hasMore() {
+ return pn * ps < tc;
+ }
+
+ /**
+ * 自定义每页数量,是否还有更多
+ */
+ public boolean isEnd(int ps) {
+ return this.pn * ps >= tc;
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/SubPrice.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/SubPrice.kt
new file mode 100644
index 0000000..654821d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/SubPrice.kt
@@ -0,0 +1,31 @@
+package com.remax.visualnovel.entity.response
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Created by HJW on 2020/12/2
+ * vip商品列表
+ */
+
+@Parcelize
+data class SubPrice(
+ val chargeAmount: Long,//赠送的BUFF
+ val memberType: String,
+ val discount: String,
+ val payAmount: Long,
+ val monthlyPrice: Long,
+ val period: String,//MONTH QUARTER YEAR
+ val productId: String,
+ var isChecked: Boolean,
+ var billingPeriod: String? = null,
+ var formattedPrice: String? = null,
+ var priceCurrencyCode: String? = null,
+ var priceAmountMicros: Long? = null,
+) : Parcelable {
+ companion object {
+ const val P1M = "P1M"
+ const val P3M = "P3M"
+ const val P1Y = "P1Y"
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Token.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Token.kt
new file mode 100644
index 0000000..f7c9c02
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Token.kt
@@ -0,0 +1,8 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/8/25
+ */
+data class Token(
+ val token: String
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/UserSubInfo.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/UserSubInfo.kt
new file mode 100644
index 0000000..3c47d61
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/UserSubInfo.kt
@@ -0,0 +1,31 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2021/8/21
+ */
+data class UserSubInfo(
+ var platform: String? = null,
+ var purchaseToken: String? = null,
+ /**
+ * 创建时间
+ */
+ val createTime: Long? = null,
+
+ /**
+ * 编辑时间
+ */
+ val editTime: Long? = null,
+
+ /**
+ * 过期时间
+ */
+ val expTime: Long? = null,
+ /**
+ * 会员类型
+ */
+ val memberType: String? = null,
+ /**
+ * 产品ID 或者说 订阅计划ID
+ */
+ val productId: String? = null,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/VipItemPrivilege.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/VipItemPrivilege.kt
new file mode 100644
index 0000000..2350f06
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/VipItemPrivilege.kt
@@ -0,0 +1,47 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2022/6/1
+ */
+data class VipItemPrivilege(
+ val id: Int,
+ val title: String,
+ val desc: String,
+ val img: String,
+ val code: String
+) {
+ companion object {
+ val privileges =
+ listOf(ADD_CRUSH_COIN, ADD_CREATE_AI, ADD_ALBUM_CREATE, AUTO_PLAY_VOICE, CUSTOM_CHAT_BUBBLE, SPECIAL_GIFT)
+
+ //增加coin
+ const val ADD_CRUSH_COIN = "ADD_CRUSH_COIN"
+
+ //增加创建ai个数
+ const val ADD_CREATE_AI = "ADD_CREATE_AI"
+
+ //增加相册创建数
+ const val ADD_ALBUM_CREATE = "ADD_ALBUM_CREATE"
+
+ //增加自动播放
+ const val AUTO_PLAY_VOICE = "AUTO_PLAY_VOICE"
+
+ //增加自定义气泡
+ const val CUSTOM_CHAT_BUBBLE = "CUSTOM_CHAT_BUBBLE"
+
+ //增加特殊礼物
+ const val SPECIAL_GIFT = "SPECIAL_GIFT"
+ }
+}
+
+data class Membership(
+ /**
+ * 用户会员权限列表
+ */
+ val memberPrivList: List? = null,
+ /**
+ * 用户会员信息
+ */
+ val userMemberInfo: UserSubInfo? = null
+
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/VoiceASR.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/VoiceASR.kt
new file mode 100644
index 0000000..39f0dc8
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/VoiceASR.kt
@@ -0,0 +1,9 @@
+package com.remax.visualnovel.entity.response
+
+/**
+ * Created by HJW on 2025/8/28
+ */
+data class VoiceASR(
+ val content:String,
+ val duration:Long,
+)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Wallet.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Wallet.kt
new file mode 100644
index 0000000..f5e6b6f
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Wallet.kt
@@ -0,0 +1,25 @@
+package com.remax.visualnovel.entity.response
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Created by HJW on 2024/8/5
+ */
+@Parcelize
+data class Wallet(
+ /**
+ * 现页面上显示的Balance为可用于付款的金额,实际为后加上的charge字段(用户充值金额)
+ */
+ var balance: Long,
+ /**
+ * 现页面上显示的Income为用户收入金额,实际为之前定义的balance等字段
+ */
+ val income: Long?,
+ val withdrawable: Long?, //可提现
+ /**
+ * 待入账收入
+ */
+ val awaitingIncome: Long?, //待入账
+) : Parcelable
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnAILiked.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnAILiked.kt
new file mode 100644
index 0000000..e7f53dc
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnAILiked.kt
@@ -0,0 +1,9 @@
+package com.remax.visualnovel.event.model
+
+/**
+ * Created by HJW on 2025/9/24
+ */
+data class OnAILiked(
+ val aiId: String?,
+ val liked: Boolean?
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserAIEvents.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserAIEvents.kt
new file mode 100644
index 0000000..37c76b8
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserAIEvents.kt
@@ -0,0 +1,34 @@
+package com.remax.visualnovel.event.modular
+
+import com.remax.visualnovel.entity.request.AIIsShowDTO
+import com.remax.visualnovel.event.model.OnAILiked
+import com.pengxr.modular.eventbus.facade.annotation.EventGroup
+
+/**
+ * Created by HJW on 2023/5/18
+ * 当前用户相关的事件
+ */
+@EventGroup(moduleName = "AI", autoClear = true)
+interface UserAIEvents {
+
+ /**
+ * 用户的AI角色发生了变更
+ * 包括: 创建AI,编辑AI,编辑AI相册,删除AI,修改AI默认图等等
+ */
+ fun onAICharacterChanges(): String?
+
+ /**
+ * AI修改了默认图
+ */
+ fun onAIHomeImageChanges(): String
+
+ /**
+ * AI的心动开关改变
+ */
+ fun onAIHeartIsOpenChanged(): AIIsShowDTO
+
+ /**
+ * AI点赞状态变更
+ */
+ fun onAILikedChanged(): OnAILiked
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/WalletEvents.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/WalletEvents.kt
new file mode 100644
index 0000000..34fa373
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/WalletEvents.kt
@@ -0,0 +1,27 @@
+package com.remax.visualnovel.event.modular
+
+import com.remax.visualnovel.entity.response.Wallet
+import com.pengxr.modular.eventbus.facade.annotation.EventGroup
+
+/**
+ * Created by HJW on 2023/5/18
+ * 用户钱包相关的事件,比如充值
+ */
+@EventGroup(moduleName = "wallet", autoClear = true)
+interface WalletEvents {
+
+ /**
+ * 充值成功
+ */
+ fun chargeSucceeded()
+
+ /**
+ * 钱包余额更新成功
+ */
+ fun buffBalanceUpdateSucceeded(): Wallet?
+
+ /**
+ * google订阅成功
+ */
+ fun onGoogleSubSucceeded(): String?
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/gift/GiftManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/gift/GiftManager.kt
new file mode 100644
index 0000000..acba02c
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/gift/GiftManager.kt
@@ -0,0 +1,46 @@
+package com.remax.visualnovel.manager.gift
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import com.remax.visualnovel.api.factory.ServiceFactory
+import com.remax.visualnovel.entity.request.Gift
+import com.remax.visualnovel.repository.api.DictRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * Created by HJW on 2025/9/17
+ */
+object GiftManager : DefaultLifecycleObserver {
+
+ var gifts: List? = null
+ private set(value) {
+ value?.firstOrNull()?.select = true
+ field = value
+ }
+
+ private val dictRepository by lazy {
+ DictRepository(ServiceFactory.createService())
+ }
+
+ fun initSelect(){
+ gifts?.forEachIndexed { index, gift ->
+ gift.select = index == 0
+ }
+ }
+
+ fun getGift() {
+ CoroutineScope(Dispatchers.IO).launch {
+ dictRepository.getGiftDict().transformResult({
+ gifts = it?.datas
+ })
+ }
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ if (gifts == null) {
+ getGift()
+ }
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt
index 468b199..de44bfe 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt
@@ -1,11 +1,18 @@
package com.remax.visualnovel.manager.login
import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
+import com.remax.visualnovel.api.factory.ServiceFactory
import com.remax.visualnovel.entity.response.User
import com.remax.visualnovel.event.model.OnLoginEvent
import com.remax.visualnovel.event.model.tab.MainTab
+import com.remax.visualnovel.manager.nim.NimManager
+import com.remax.visualnovel.repository.api.MessageRepository
import com.remax.visualnovel.utils.Routers
import com.remax.visualnovel.ui.main.MainActivity
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
/**
* Created by HJW on 2025/7/11
@@ -20,6 +27,7 @@ object LoginManager {
set(value) {
loginInfoSave.putUser(value)
field = value
+ WalletManager.refreshWallet()
}
var token: String? = null
@@ -51,6 +59,7 @@ object LoginManager {
token = null
EventDefineOfUserEvents.onLoginStatusChanged().post(OnLoginEvent(OnLoginEvent.LOGOUT))
MainActivity.start(MainTab.TAB_BOOKS)
+ NimManager.logout()
}
/**
@@ -73,8 +82,16 @@ object LoginManager {
this.token = token
}
+ var contactUnreadCount: Int = 0
+ set(value) {
+ field = value
+ EventDefineOfUserEvents.onUserUnReadChanged().post(null)
+ }
+ get() {
+ return field + if (NimManager.isLogin) NimManager.totalUnreadCount else 0
+ }
- /*private val messageRepository by lazy {
+ private val messageRepository by lazy {
MessageRepository(ServiceFactory.createService())
}
@@ -82,5 +99,5 @@ object LoginManager {
CoroutineScope(Dispatchers.Main).launch {
messageRepository.getMessageStat()
}
- }*/
+ }
}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/FetchResult.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/FetchResult.kt
new file mode 100644
index 0000000..44289f5
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/FetchResult.kt
@@ -0,0 +1,93 @@
+package com.remax.visualnovel.manager.nim
+
+import android.content.Context
+
+/**
+ * Created by HJW on 2025/8/20
+ */
+class FetchResult(var loadStatus: LoadStatus?) {
+ var type = FetchType.Init
+ var typeIndex = 0
+ var data: T? = null
+ var error: ErrorMsg? = null
+ var extraInfo: Any? = null
+
+ constructor(loadStatus: LoadStatus?, data: T?) : this(loadStatus) {
+ this.data = data
+ }
+
+ constructor(type: FetchType) : this(LoadStatus.Success) {
+ this.type = type
+ }
+
+ constructor(data: T?) : this(LoadStatus.Success) {
+ this.data = data
+ }
+
+ constructor(type: FetchType, data: T?) : this(LoadStatus.Success) {
+ this.type = type
+ this.data = data
+ }
+
+ constructor(code: Int, msg: String?) : this(LoadStatus.Error) {
+ error = ErrorMsg(code, msg)
+ }
+
+ fun setError(code: Int, msg: String?) {
+ loadStatus = LoadStatus.Error
+ error = ErrorMsg(code, msg)
+ }
+
+ fun setError(code: Int, msgRes: Int) {
+ loadStatus = LoadStatus.Error
+ error = ErrorMsg(code, msgRes)
+ }
+
+ fun setFetchType(type: FetchType) {
+ this.type = type
+ }
+
+ fun isSuccess(): Boolean {
+ return loadStatus == LoadStatus.Success
+ }
+
+ fun setStatus(loadStatus: LoadStatus?) {
+ this.loadStatus = loadStatus
+ }
+
+ fun errorMsg(): ErrorMsg? {
+ return error
+ }
+
+ fun getErrorMsg(context: Context): String? {
+ if (error != null) {
+ if (!error?.msg.isNullOrBlank()) {
+ return error?.msg
+ }
+ if (error?.res != null) {
+ return context.resources.getString(error!!.res)
+ }
+ }
+ return null
+ }
+
+ enum class FetchType {
+ Init, Add, Update, Remind, Remove
+ }
+
+ class ErrorMsg {
+ var code: Int
+ var res = 0
+ var msg: String? = null
+
+ constructor(code: Int, errorRes: Int) {
+ this.code = code
+ res = errorRes
+ }
+
+ constructor(code: Int, msg: String?) {
+ this.code = code
+ this.msg = msg
+ }
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/LoadStatus.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/LoadStatus.kt
new file mode 100644
index 0000000..35584d3
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/LoadStatus.kt
@@ -0,0 +1,11 @@
+package com.remax.visualnovel.manager.nim
+
+/**
+ * Created by HJW on 2025/8/20
+ */
+enum class LoadStatus {
+
+ Loading, Error, Success, Finish
+
+}
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/NimManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/NimManager.kt
new file mode 100644
index 0000000..b65b74a
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/nim/NimManager.kt
@@ -0,0 +1,518 @@
+package com.remax.visualnovel.manager.nim
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.base.app.CommonApplicationProxy
+import com.remax.visualnovel.entity.imbean.RecentContactWrapper
+import com.remax.visualnovel.entity.response.NimBean
+import com.remax.visualnovel.extension.resumeWithActive
+import com.remax.visualnovel.extension.toast
+import com.remax.visualnovel.manager.login.LoginManager
+import com.remax.visualnovel.ui.main.MainActivity
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.google.gson.Gson
+import com.netease.nimlib.sdk.NIMClient
+import com.netease.nimlib.sdk.NotificationFoldStyle
+import com.netease.nimlib.sdk.Observer
+import com.netease.nimlib.sdk.RequestCallback
+import com.netease.nimlib.sdk.StatusBarNotificationConfig
+import com.netease.nimlib.sdk.msg.MsgService
+import com.netease.nimlib.sdk.msg.constant.MsgStatusEnum
+import com.netease.nimlib.sdk.msg.constant.NotificationExtraTypeEnum
+import com.netease.nimlib.sdk.msg.constant.SessionTypeEnum
+import com.netease.nimlib.sdk.msg.model.IMMessage
+import com.netease.nimlib.sdk.v2.V2NIMError
+import com.netease.nimlib.sdk.v2.auth.V2NIMLoginListener
+import com.netease.nimlib.sdk.v2.auth.V2NIMLoginService
+import com.netease.nimlib.sdk.v2.auth.enums.V2NIMLoginClientChange
+import com.netease.nimlib.sdk.v2.auth.enums.V2NIMLoginStatus
+import com.netease.nimlib.sdk.v2.auth.model.V2NIMKickedOfflineDetail
+import com.netease.nimlib.sdk.v2.auth.model.V2NIMLoginClient
+import com.netease.nimlib.sdk.v2.auth.option.V2NIMLoginOption
+import com.netease.nimlib.sdk.v2.conversation.V2NIMConversationListener
+import com.netease.nimlib.sdk.v2.conversation.V2NIMConversationService
+import com.netease.nimlib.sdk.v2.conversation.enums.V2NIMConversationType
+import com.netease.nimlib.sdk.v2.conversation.model.V2NIMConversation
+import com.netease.nimlib.sdk.v2.conversation.params.V2NIMConversationFilter
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageService
+import com.netease.nimlib.sdk.v2.message.config.V2NIMMessageConfig
+import com.netease.nimlib.sdk.v2.message.params.V2NIMSendMessageParams
+import com.netease.nimlib.sdk.v2.user.V2NIMUserService
+import com.netease.nimlib.sdk.v2.utils.V2NIMConversationIdUtil
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import timber.log.Timber
+
+
+/**
+ * Created by HJW on 2020/9/28
+ */
+object NimManager : DefaultLifecycleObserver {
+ //没钱发送消息
+ const val SEND_IM_INSUFFICIENT_BALANCE = 20000
+
+ //发消息等级不足
+ const val SEND_IM_LEVEL_ERROR = 20001
+
+ var account = ""
+ private set
+
+ fun log(content: String) {
+ Timber.i("云信Manager $content")
+ }
+
+ override fun onCreate(owner: LifecycleOwner) {
+ addLoginListener(true)
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ _conversationSyncLiveData.value = false
+ _updateLiveData.value = null
+ _deleteLiveData.value = null
+ _addLiveData.value = null
+ addLoginListener(false)
+ addConversationListener(false)
+ }
+
+ private val loginListener = object : V2NIMLoginListener {
+ override fun onLoginStatus(status: V2NIMLoginStatus?) {
+ when (status) {
+ V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED -> {
+ log("已登录")
+ addConversationListener(true)
+ }
+
+ V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGOUT -> {
+ log("已登出")
+ addConversationListener(false)
+ }
+
+ else -> {
+ log("登录状态回调 $status")
+ }
+ }
+ }
+
+ override fun onLoginFailed(error: V2NIMError?) {
+ addConversationListener(false)
+ log("onLoginFailed ${error?.code} ${error?.desc}")
+ }
+
+ override fun onKickedOffline(detail: V2NIMKickedOfflineDetail?) {
+ LoginManager.logout()
+ }
+
+ override fun onLoginClientChanged(
+ change: V2NIMLoginClientChange?,
+ clients: List?
+ ) {
+
+ }
+ }
+
+ private val v2NIMConversationService by lazy {
+ NIMClient.getService(V2NIMConversationService::class.java)
+ }
+
+ private val v2NIMLoginService by lazy {
+ NIMClient.getService(V2NIMLoginService::class.java)
+ }
+
+ private val v2MessageService by lazy {
+ NIMClient.getService(V2NIMMessageService::class.java)
+ }
+
+
+ /**
+ * 监听云信登录状态
+ */
+ fun addLoginListener(register: Boolean) {
+ if (register) {
+ v2NIMLoginService.addLoginListener(loginListener)
+ } else {
+ v2NIMLoginService.removeLoginListener(loginListener)
+ }
+ }
+
+ /**
+ * 注册监听会话列表
+ */
+ private fun addConversationListener(register: Boolean) {
+ if (register) {
+ v2NIMConversationService.addConversationListener(conversationListener)
+ } else {
+ v2NIMConversationService.removeConversationListener(conversationListener)
+ }
+ }
+
+ private val _conversationSyncLiveData = MutableLiveData(false)
+ val conversationSyncLiveData: LiveData = _conversationSyncLiveData
+
+ // 会话变化LiveData,用于通知会话信息变化变更
+ private val _updateLiveData = MutableLiveData?>()
+ val updateLiveData: LiveData?> = _updateLiveData
+
+ // 删除会话LiveData,用于通知会话删除结果
+ private val _deleteLiveData = MutableLiveData?>()
+ val deleteLiveData: LiveData?> = _deleteLiveData
+
+ // 创建会话
+ private val _addLiveData = MutableLiveData()
+ val addLiveData: LiveData = _addLiveData
+
+ private val conversationListener = object : V2NIMConversationListener {
+ /**
+ * 数据同步开始回调。
+ */
+ override fun onSyncStarted() {
+
+ }
+
+ /**
+ * 数据同步结束回调。如果数据同步已开始,建议在数据同步结束后再进行其他会话操作。
+ */
+ override fun onSyncFinished() {
+ log("可以开始拉会话列表")
+ EventDefineOfUserEvents.onUserUnReadChanged().post(null)
+ MainScope().launch {
+ _conversationSyncLiveData.value = true
+ }
+ }
+
+ /**
+ * 数据同步失败回调。
+ */
+ override fun onSyncFailed(error: V2NIMError?) {
+
+ }
+
+ /**
+ * 会话成功创建回调。
+ */
+ override fun onConversationCreated(conversation: V2NIMConversation) {
+ log("会话成功创建 $conversation")
+ _addLiveData.value = RecentContactWrapper(conversation)
+ }
+
+ /**
+ * 主动删除会话回调。
+ */
+ override fun onConversationDeleted(conversationIds: List) {
+ log("会话被删除 $conversationIds")
+ _deleteLiveData.value = conversationIds
+ }
+
+ /**
+ * 会话变更回调。当置顶会话、会话有新消息、主动更新会话成功时会触发该回调。
+ */
+ override fun onConversationChanged(conversationList: List) {
+ log("会话变更回调 $conversationList")
+ _updateLiveData.value = conversationList.map { RecentContactWrapper(it) }
+ }
+
+ /**
+ * 会话消息总未读数变更回调。
+ */
+ override fun onTotalUnreadCountChanged(unreadCount: Int) {
+ log("会话消息总未读数变更 $unreadCount")
+ EventDefineOfUserEvents.onUserUnReadChanged().post(null)
+ }
+
+ /**
+ * 过滤后的未读数变更回调。调用 subscribeUnreadCountByFilter 方法订阅监听后,当会话过滤后的未读数变化时会返回该回调。
+ */
+ override fun onUnreadCountChangedByFilter(
+ filter: V2NIMConversationFilter?,
+ unreadCount: Int
+ ) {
+
+ }
+
+ /**
+ * 同一账号多端登录后的会话已读时间戳标记的回调。
+ */
+ override fun onConversationReadTimeUpdated(conversationId: String?, readTime: Long) {
+
+ }
+ }
+
+ /**
+ * 登录云信
+ */
+ fun login(nimBean: NimBean) {
+ v2NIMLoginService.login(
+ nimBean.accountId, nimBean.token, V2NIMLoginOption().apply {
+ retryCount = 3
+ }, {
+ log("login调用成功")
+ account = nimBean.accountId
+ loginSuccess()
+ },
+ { error ->
+ val code = error.code
+ val desc = error.desc
+ log("login调用失败 onFailed errorCode:$code desc:$desc")
+ })
+ }
+
+
+ fun logout() {
+ // 请勿在 Activity 的 `onDestroy` 中调用 `logout` 方法
+ v2NIMLoginService.logout({
+ log("logout调用成功")
+ LoginManager.contactUnreadCount = 0
+ }, { error ->
+ val code = error.code
+ val desc = error.desc
+ log("logout调用失败 onFailed errorCode:$code desc:$desc")
+ })
+ }
+
+ private var offset = 0L
+
+ /**
+ * 分页获取所有会话列表
+ */
+ data class Conversation(
+ val list: List,
+ val hasMore: Boolean
+ )
+
+ var allConversation: MutableList? = null
+
+ private var queryConversationStart = false
+
+ fun getUserList(accountIds: List?) {
+ NIMClient.getService(V2NIMUserService::class.java).getUserList(accountIds?.filterNotNull(), {
+ log("获取用户信息成功 $accountIds")
+ }) {
+ log("获取用户信息失败 onFailed errorCode:${it.code} desc:$${it.desc}")
+ }
+ }
+
+ suspend fun getConversationList(isRefresh: Boolean) =
+ suspendCancellableCoroutine { coroutine ->
+ if (queryConversationStart) {
+ log("queryConversation,has Started return")
+ }
+ queryConversationStart = true
+ coroutine.invokeOnCancellation {
+ coroutine.resumeWithActive(Conversation(emptyList(), false))
+ queryConversationStart = false
+ }
+ if (isRefresh) {
+ allConversation = null
+ offset = 0L
+ }
+ val pageLimit = 100
+ v2NIMConversationService.getConversationList(offset, pageLimit, {
+ offset = it.offset
+ val conversationList = it.conversationList
+ val hasMore = conversationList.size == pageLimit
+ log("拉到的会话列表 $conversationList")
+ queryConversationStart = false
+ coroutine.resumeWithActive(Conversation(conversationList, hasMore))
+ }) {
+ queryConversationStart = false
+ log("拉取会话列表失败 onFailed errorCode:${it.code} desc:$${it.desc}")
+ coroutine.resumeWithActive(Conversation(emptyList(), true))
+ }
+ }
+
+ /**
+ * 获取会话
+ * @param conversationId 会话 ID
+ */
+ fun getConversation(conversationId: String, success: () -> Unit, error: (errorCode: Int) -> Unit) {
+ v2NIMConversationService.getConversation(conversationId, { conversation ->
+ success.invoke()
+ log("获取会话${conversationId}成功: $conversation")
+ }) {
+ error.invoke(it.code)
+ log("获取会话${conversationId}失败 onFailed errorCode:${it.code} desc:$${it.desc}")
+ }
+ }
+
+ /**
+ * 创建会话
+ */
+ fun createConversation(accountId: String?, success: () -> Unit, error: (errorCode: Int) -> Unit) {
+ val conversationId = V2NIMConversationIdUtil.p2pConversationId(accountId)
+ v2NIMConversationService.createConversation(conversationId, { conversation ->
+ log("创建会话成功: $conversation")
+ success.invoke()
+ }) {
+ error.invoke(it.code)
+ log("创建${conversationId}会话失败accountId:$accountId onFailed errorCode:${it.code} desc:$${it.desc}")
+ }
+ }
+
+ /**
+ * 获取登录连接状态
+ * IM 登录连接状态表示当前登录的 NIM SDK 实例与网易云信服务端的长连接状态,也可以理解为用户客户端和网易云信服务端的网络连接状态。
+ * V2NIM_CONNECT_STATUS_DISCONNECTED(0) SDK 未连接服务端
+ * V2NIM_CONNECT_STATUS_CONNECTED(1) SDK 已连接服务端
+ * V2NIM_CONNECT_STATUS_CONNECTING(2) SDK 正在与服务端连接
+ * V2NIM_CONNECT_STATUS_WAITING(3) SDK 正在等待与服务端重连
+ */
+ fun getConnectStatus() = v2NIMLoginService.connectStatus
+
+ val isLogin: Boolean
+ get() = v2NIMLoginService.loginStatus == V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED
+
+ private fun loginSuccess() {
+ log("loginSuccess 当前状态:${v2NIMLoginService.loginStatus}")
+ when (v2NIMLoginService.loginStatus) {
+ V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED -> {
+ addConversationListener(true)
+ }
+
+ else -> {
+
+ }
+ }
+
+ //是否需要开启通知栏消息提醒, MessageAlter已经被去掉,这里需要直接设置为开启
+ val config = StatusBarNotificationConfig().apply {
+ notificationExtraType = NotificationExtraTypeEnum.MESSAGE
+ notificationSmallIconId = R.mipmap.book_archive
+ notificationEntrance = MainActivity::class.java
+ notificationFoldStyle = NotificationFoldStyle.CONTACT
+ ring = false
+ vibrate = false
+ downTimeToggle = false
+ }
+ NIMClient.updateStatusBarNotificationConfig(config)
+ NIMClient.toggleNotification(true)
+ setPushAlter(true)
+ updateMyIMUserInfo()
+ }
+
+ /**
+ * 获取会话总未读数
+ */
+ val totalUnreadCount: Int
+ get() {
+ val unreadCount = v2NIMConversationService.totalUnreadCount
+ log("会话消息总未读数 $unreadCount")
+ return unreadCount
+ }
+
+
+ /**
+ * 标记会话已读
+ * 并触发 onTotalUnreadCountChanged、onConversationChanged 和 onUnreadCountChangedByFilter 回调,同步数据库和缓存。
+ */
+ fun clearUnreadCountByIds(conversationId: String) {
+ v2NIMConversationService.clearUnreadCountByIds(listOf(conversationId), {
+ log("标记会话已读成功 ${Gson().toJson(it)}")
+ }) {
+ log("标记会话已读失败 onFailed errorCode:${it.code} desc:$${it.desc}")
+ }
+ }
+
+ /**
+ * 未读数清零
+ */
+ fun clearTotalUnreadCount() {
+ v2NIMConversationService.clearTotalUnreadCount({
+ log("未读数清零成功")
+ }) {
+ log("未读数清零失败 onFailed errorCode:${it.code} desc:$${it.desc}")
+ }
+ }
+
+ private fun updateMyIMUserInfo() {
+// val user = getUserInfo(account)
+// try {
+// user?.extensionMap?.let { extension ->
+//
+// val fields = kotlin.collections.HashMap().apply {
+// put(UserInfoFieldEnum.EXTEND, Gson().toJson(extension))
+// }
+// NIMClient.getService(UserService::class.java).updateUserInfo(fields)
+// .setCallback(object : RequestCallbackWrapper() {
+// override fun onResult(code: Int, result: Void?, exception: Throwable?) {
+// Timber.d("updateUserInfo code: $code}")
+// Timber.d("updateUserInfo res: ${user.extension}")
+// }
+// })
+// }
+// } catch (e: Exception) {
+// Timber.d("NIMClient-updateUserInfo-error : ${e.localizedMessage}")
+// }
+ }
+
+ private val _messageStatus = MutableLiveData()
+ val messageStatus: LiveData = _messageStatus
+
+ private val messageStatusObserver: Observer = Observer {
+ Timber.d("observeMsgStatus:${Gson().toJson(it)}")
+ if (it.status == MsgStatusEnum.success) {
+ // 1、根据sessionId判断是否是自己的消息
+ // 2、更改内存中消息的状态
+ // 3、刷新界面
+ _messageStatus.value = it
+ }
+ }
+
+
+ fun v2SendMessage(v2Message: V2NIMMessage, accountId: String?, errorCallback: ((Int) -> Unit)? = null) {
+ val conversationId =
+ V2NIMConversationIdUtil.conversationId(accountId, V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P)
+
+ val messageConfig = V2NIMMessageConfig.V2NIMMessageConfigBuilder
+ .builder()
+ .build()
+ val sendMessageParams = V2NIMSendMessageParams.V2NIMSendMessageParamsBuilder
+ .builder()
+ .withMessageConfig(messageConfig)
+ .build()
+ v2MessageService.sendMessage(v2Message, conversationId, sendMessageParams, { result ->
+ val message = result.message
+ log("发送消息成功: $message")
+ }, { failure ->
+ log("发送消息, code: " + failure.code + ", message: " + failure.desc)
+ when (failure.code) {
+ SEND_IM_INSUFFICIENT_BALANCE -> {
+ CommonApplicationProxy.application.toast(R.string.insufficient_balance)
+ WalletManager.refreshWallet()
+ }
+ }
+ errorCallback?.invoke(failure.code)
+ }) { progress ->
+ log("发送消息进度: $progress")
+ }
+ }
+
+ /**
+ * 打开、关闭推送服务
+ */
+ fun setPushAlter(isOpen: Boolean, callback: RequestCallback? = null) {
+
+ }
+
+ fun setChattingAccount(account: String) {
+ // 进入聊天界面,建议放在onResume中。表示来自account的消息无需进行消息提醒。
+ NIMClient.getService(MsgService::class.java).setChattingAccount(account, SessionTypeEnum.P2P)
+ }
+
+ fun setChattingAccountAll() {
+ // 进入最近联系人列表界面,建议放在onResume中。表示所有消息无需进行消息提醒。
+ NIMClient.getService(MsgService::class.java)
+ .setChattingAccount(MsgService.MSG_CHATTING_ACCOUNT_ALL, SessionTypeEnum.None)
+ }
+
+ fun setChattingAccountNone() {
+ // 退出聊天界面或离开最近联系人列表界面,建议放在onPause中。表示所有消息都可以进行消息提醒。
+ NIMClient.getService(MsgService::class.java)
+ .setChattingAccount(MsgService.MSG_CHATTING_ACCOUNT_NONE, SessionTypeEnum.None)
+ }
+
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/pay/GooglePayManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/pay/GooglePayManager.kt
new file mode 100644
index 0000000..d5c27b5
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/pay/GooglePayManager.kt
@@ -0,0 +1,476 @@
+package com.remax.visualnovel.manager.pay
+
+import android.app.Activity
+import android.os.Handler
+import android.os.Looper
+import android.os.Message
+import com.android.billingclient.api.AcknowledgePurchaseParams
+import com.android.billingclient.api.BillingClient
+import com.android.billingclient.api.BillingClientStateListener
+import com.android.billingclient.api.BillingFlowParams
+import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode
+import com.android.billingclient.api.BillingResult
+import com.android.billingclient.api.ConsumeParams
+import com.android.billingclient.api.ProductDetails
+import com.android.billingclient.api.Purchase
+import com.android.billingclient.api.PurchasesUpdatedListener
+import com.android.billingclient.api.QueryProductDetailsParams
+import com.android.billingclient.api.QueryPurchasesParams
+import com.remax.visualnovel.R
+import com.remax.visualnovel.api.factory.ServiceFactory
+import com.remax.visualnovel.app.base.BaseBindingActivity
+import com.remax.visualnovel.app.base.app.CommonApplicationProxy
+import com.remax.visualnovel.constant.StatusCode
+import com.remax.visualnovel.entity.request.ChargeProduct
+import com.remax.visualnovel.entity.request.ValidateTransactionDTO
+import com.remax.visualnovel.entity.response.SubPrice
+import com.remax.visualnovel.entity.response.base.parseData
+import com.remax.visualnovel.extension.launchFlow
+import com.remax.visualnovel.repository.api.PayRepository
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.remax.visualnovel.utils.TimeUtils
+import com.remax.visualnovel.utils.analytics.AnalyticsUtils
+import com.google.gson.Gson
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents
+import com.remax.visualnovel.configs.NovelApplication
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.math.BigDecimal
+import java.math.RoundingMode
+
+/**
+ * Created by HJW on 2022/6/28
+ */
+object GooglePayManager : PurchasesUpdatedListener {
+
+ private val payRepository by lazy {
+ PayRepository(ServiceFactory.createService())
+ }
+
+ private var isConnected = false
+
+ private var billingClient: BillingClient? = null
+
+ private var queryProductTime = 0L
+
+ var productDetails = listOf()
+ private set
+ var subProductDetails: MutableList = arrayListOf()
+ private set
+
+ private val mHandler: Handler = object : Handler(Looper.getMainLooper()) {
+ override fun handleMessage(msg: Message) {
+ if (msg.what == 100) {
+ queryPurchases()
+ }
+ }
+ }
+
+ fun startConnection(callback: () -> Unit, errorCallback: (() -> Unit)? = null) {
+ if (billingClient == null) {
+ billingClient =
+ BillingClient.newBuilder(CommonApplicationProxy.application).enablePendingPurchases().setListener(this)
+ .build()
+ }
+ if (isConnected && billingClient?.isReady == true) {
+ Timber.d("Google Play正常连接")
+ callback.invoke()
+ } else {
+ billingClient?.startConnection(object : BillingClientStateListener {
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ isConnected = true
+ callback.invoke()
+ } else {
+ isConnected = false
+ errorCallback?.invoke()
+ Timber.e("GooglePay连接失败")
+ }
+ }
+
+ override fun onBillingServiceDisconnected() {
+ isConnected = false
+ // Try to restart the connection on the next request to
+ // Google Play by calling the startConnection() method.
+ Timber.e("GooglePay断开连接")
+ }
+ })
+ }
+ }
+
+ /**
+ * 检查本地商品是否为空
+ */
+ fun checkProductDetails() {
+ startConnection({
+ /**
+ * 如果距离上次查询时间超过10分钟,则清空重新查询,防止商品token过期
+ */
+ if (System.currentTimeMillis() - queryProductTime > 10 * TimeUtils.ONE_MINUTE) {
+ queryProductTime = System.currentTimeMillis()
+ queryAllProductDetails()
+ } else {
+ if (productDetails.isEmpty()) {
+ queryAllProductDetails(isSubs = false)
+ }
+ if (subProductDetails.isEmpty()) {
+ queryAllProductDetails(isConsume = false)
+ }
+ }
+ })
+ }
+
+ private var productList = arrayListOf()
+ private var subList = arrayListOf()
+
+ private fun queryProductDetails() {
+ val immutableList = arrayListOf()
+ productList.forEach { product ->
+ immutableList.add(
+ QueryProductDetailsParams.Product.newBuilder()
+ .setProductId(product.productId)
+ .setProductType(BillingClient.ProductType.INAPP)
+ .build()
+ )
+ }
+ if (immutableList.isNotEmpty()) {
+ val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
+ .setProductList(immutableList)
+ .build()
+ billingClient?.queryProductDetailsAsync(
+ queryProductDetailsParams
+ ) { billingResult, productDetailsList ->
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ productDetailsList.forEach {
+ Timber.d("GooglePay 查询一次性商品本地化 : $it")
+ }
+ productDetails = productDetailsList
+ queryPurchases()
+ }
+ }
+ }
+ }
+
+ private fun querySubProductDetails() {
+ val immutableList = arrayListOf()
+ subList.forEach { product ->
+ immutableList.add(
+ QueryProductDetailsParams.Product.newBuilder()
+ .setProductId(product.productId)
+ .setProductType(BillingClient.ProductType.SUBS)
+ .build()
+ )
+ }
+ if (immutableList.isNotEmpty()) {
+ val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
+ .setProductList(immutableList)
+ .build()
+ billingClient?.queryProductDetailsAsync(
+ queryProductDetailsParams
+ ) { billingResult, productDetailsList ->
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ productDetailsList.forEach {
+ Timber.d("GooglePay 查询订阅商品本地化 : $it")
+ }
+ subProductDetails.clear()
+ subProductDetails.addAll(productDetailsList)
+ }
+ }
+ }
+ }
+
+ /**
+ * 查询所有商品
+ */
+ fun queryAllProductDetails(isConsume: Boolean = true, isSubs: Boolean = true) {
+ if (isConsume) {
+ CoroutineScope(Dispatchers.Main).launch {
+ launchFlow({
+ payRepository.getProducts()
+ }).collect {
+ it.parseData({
+ onSuccess = { productInfo ->
+ productList.clear()
+ productList.addAll(productInfo?.productList ?: emptyList())
+ queryProductDetails()
+ }
+
+ onFailed = { _, errorMsg ->
+ Timber.e("GooglePay google支付单例查询充值接口报错 msg:${errorMsg}")
+ }
+ })
+ }
+ }
+ }
+
+ if (isSubs) {
+ CoroutineScope(Dispatchers.Main).launch {
+ launchFlow({
+ payRepository.getSubPriceList()
+ }).collect {
+ it.parseData({
+ onSuccess = { group ->
+ subList.clear()
+ subList.addAll(group?: emptyList())
+ querySubProductDetails()
+ }
+
+ onFailed = { _, errorMsg ->
+ Timber.e("GooglePay google支付单例查询订阅商品接口报错 msg:${errorMsg}")
+ }
+ })
+ }
+ }
+ }
+ }
+
+ /**
+ * 发起支付
+ */
+ fun pay(
+ activity: Activity,
+ productId: String,
+ isSub: Boolean = false,
+ oldPurchaseToken: String = "",
+ tradeNo: String = "",
+ errorCallback: (() -> Unit)? = null
+ ) {
+ startConnection({
+ queryProductDetails(
+ activity,
+ productId,
+ if (isSub) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
+ oldPurchaseToken,
+ tradeNo,
+ errorCallback
+ )
+ }) {
+ errorCallback?.invoke()
+ }
+ }
+
+ /**
+ * 查询商品
+ */
+ private fun queryProductDetails(
+ activity: Activity,
+ productId: String,
+ type: String,
+ oldPurchaseToken: String = "",
+ tradeNo: String = "",
+ errorCallback: (() -> Unit)? = null
+ ) {
+ val queryProductDetailsParams =
+ QueryProductDetailsParams.newBuilder()
+ .setProductList(
+ arrayListOf(
+ QueryProductDetailsParams.Product.newBuilder()
+ .setProductId(productId)
+ .setProductType(type)
+ .build()
+ )
+ )
+ .build()
+
+ billingClient?.queryProductDetailsAsync(
+ queryProductDetailsParams
+ ) { billingResult, productDetailsList ->
+ // check billingResult
+ if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
+ Timber.e("GooglePay queryProductDetailsAsync onFailed")
+ errorCallback?.invoke()
+ } else {
+ /**
+ * 启动购买流程
+ */
+ productDetailsList.forEach { productDetails ->
+ val flowParamsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder()
+ // retrieve a value for "productDetails" by calling queryProductDetailsAsync()
+ .setProductDetails(productDetails)
+ if (type == BillingClient.ProductType.SUBS) {
+ val offerToken = productDetails?.subscriptionOfferDetails?.get(0)?.offerToken ?: ""
+ flowParamsBuilder.setOfferToken(offerToken)
+ }
+ val flowParams = flowParamsBuilder.build()
+ val billingFlowParamsBuilder =
+ BillingFlowParams.newBuilder().setProductDetailsParamsList(arrayListOf(flowParams))
+ if (type == BillingClient.ProductType.SUBS && oldPurchaseToken.isNotEmpty()) {
+ billingFlowParamsBuilder.setSubscriptionUpdateParams(
+ BillingFlowParams.SubscriptionUpdateParams.newBuilder()
+ // purchaseToken can be found in Purchase#getPurchaseToken
+ .setOldPurchaseToken(oldPurchaseToken)
+ .setSubscriptionReplacementMode(ReplacementMode.DEFERRED)
+// .setReplaceProrationMode(BillingFlowParams.ProrationMode.DEFERRED)
+ .build()
+ )
+ }
+ val billingFlowParams = billingFlowParamsBuilder
+ .setObfuscatedAccountId(tradeNo)
+ .setObfuscatedProfileId(tradeNo)
+ .build()
+ // Launch the billing flow
+ val billingFlowParamsResult = billingClient?.launchBillingFlow(activity, billingFlowParams)
+ if (billingFlowParamsResult?.responseCode != BillingClient.BillingResponseCode.OK) {
+ Timber.e("GooglePay launchBillingFlow onFailed")
+ errorCallback?.invoke()
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * 将购买操作的结果传送给实现 PurchasesUpdatedListener 接口的监听器
+ */
+ override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList?) {
+ Timber.d("GooglePay 购买操作结果回调 billingResult:$billingResult res:${Gson().toJson(purchases)} ")
+ when {
+ billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
+ queryPurchases()
+ }
+
+ billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> {
+ //取消购买
+ }
+
+ else -> {
+// showError()
+ }
+ }
+ }
+
+ /**
+ * 查询购买交易记录
+ */
+ fun queryPurchases() {
+ startConnection({
+ billingClient?.queryPurchasesAsync(
+ QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.INAPP)
+ .build()
+ ) { billingResult, purchases ->
+ Timber.d("GooglePay 查询一次性消费商品 billingResult:$billingResult res:$purchases} ")
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ if (purchases.isNotEmpty()) {
+ purchases.forEach {
+ handlePurchase(it, BillingClient.ProductType.INAPP)
+ }
+ }
+ }
+ }
+
+ billingClient?.queryPurchasesAsync(
+ QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.SUBS)
+ .build()
+ ) { billingResult, purchases ->
+ Timber.d("GooglePay 查询订阅商品 billingResult:$billingResult res:${Gson().toJson(purchases)} ")
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases.isNotEmpty()) {
+ for (purchase in purchases) {
+ handlePurchase(purchase, BillingClient.ProductType.SUBS)
+ }
+ }
+ }
+ })
+ }
+
+ /**
+ * 处理消费
+ */
+ private fun handlePurchase(purchase: Purchase, type: String) {
+ if (!purchase.isAcknowledged) {
+ //订阅
+ if (type == BillingClient.ProductType.SUBS) {
+ val build = AcknowledgePurchaseParams.newBuilder()
+ .setPurchaseToken(purchase.purchaseToken)
+ .build()
+ billingClient?.acknowledgePurchase(build) { billingResult ->
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ //订阅成功
+ EventDefineOfWalletEvents.onGoogleSubSucceeded().post(purchase.purchaseToken)
+ }
+ }
+ }
+ //一次性消费
+ else {
+ val consumeParams = ConsumeParams.newBuilder()
+ .setPurchaseToken(purchase.purchaseToken)
+ .build()
+ billingClient?.consumeAsync(consumeParams) { billingResult, purchaseToken ->
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ //消费成功
+ try {
+ var currency = "USD"
+ var price = 0.00
+ productDetails.find { it?.productId == purchase.products[0] }?.let { productDetails ->
+ currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode ?: "USD"
+ price =
+ BigDecimal(
+ productDetails.oneTimePurchaseOfferDetails?.priceAmountMicros ?: 0L
+ ).divide(
+ BigDecimal(1000000),
+ 2,
+ RoundingMode.DOWN
+ ).setScale(2, BigDecimal.ROUND_DOWN).toDouble()
+ }
+ val orderId = purchase.orderId
+ val productId = purchase.products[0]
+ Timber.d("GooglePay 本次消费的数据 orderId:$orderId productId:$productId currency:$currency price:$price")
+ validateToken(purchaseToken, productId, orderId, currency, price)
+ } catch (e: Exception) {
+ Timber.e("GooglePay 消费回调报错 msg:${e.localizedMessage}")
+ }
+ } else {
+ //消费失败, 后面查询消费记录后再次消费,否则,就只能等待退款
+ Timber.d("GooglePay consumeAsync failed")
+ mHandler.removeMessages(100)
+ mHandler.sendEmptyMessageDelayed(100, TimeUtils.ONE_MINUTE)
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * 后端入账,并且查询钱包
+ * 验证支付结果, 如果成功, 则服务器端增加buff
+ */
+ private fun validateToken(
+ purchaseToken: String,
+ productId: String,
+ orderId: String?,
+ currency: String,
+ price: Double
+ ) {
+ val currActivity = NovelApplication.getCurrentActivity()
+ val dto = ValidateTransactionDTO(productId, purchaseToken)
+ CoroutineScope(Dispatchers.IO).launch {
+ launchFlow({
+ payRepository.validateTranslation(dto)
+ }, {
+ (currActivity as? BaseBindingActivity<*>)?.showLoading()
+ }) {
+ (currActivity as? BaseBindingActivity<*>)?.hideLoading()
+ }.collect {
+ it.parseData({
+ onSuccess = {
+ (currActivity as? BaseBindingActivity<*>)?.showToast(R.string.charge_succeeded)
+ AnalyticsUtils.logAnalytics("Charge_CheckOut_Success")
+ //充值成功通知
+ EventDefineOfWalletEvents.chargeSucceeded().post(null)
+ WalletManager.refreshWallet()
+ }
+
+ onFailed = { errorCode, _ ->
+ if (errorCode == StatusCode.UNUSED_PURCHASE_TOKEN.code) {
+ WalletManager.refreshWallet()
+ }
+ }
+ })
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/AIRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/AIRepository.kt
new file mode 100644
index 0000000..86903cf
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/AIRepository.kt
@@ -0,0 +1,229 @@
+package com.remax.visualnovel.repository.api
+
+import com.remax.visualnovel.R
+import com.remax.visualnovel.api.service.AIService
+import com.remax.visualnovel.app.base.app.CommonApplicationProxy
+import com.remax.visualnovel.entity.request.AIGenerate
+import com.remax.visualnovel.entity.request.AIGenerateImage
+import com.remax.visualnovel.entity.request.AIHeadImgRequest
+import com.remax.visualnovel.entity.request.AIIDRequest
+import com.remax.visualnovel.entity.request.AlbumCreate
+import com.remax.visualnovel.entity.request.AlbumDTO
+import com.remax.visualnovel.entity.request.CardRequest
+import com.remax.visualnovel.entity.request.ChatAlbum
+import com.remax.visualnovel.entity.request.ClassificationRequest
+import com.remax.visualnovel.entity.request.PageQuery
+import com.remax.visualnovel.entity.request.QueryAlbumDTO
+import com.remax.visualnovel.entity.request.SimpleCountDTO
+import com.remax.visualnovel.entity.response.Album
+import com.remax.visualnovel.entity.response.Character
+import com.remax.visualnovel.entity.response.base.ApiFailedResponse
+import com.remax.visualnovel.entity.response.base.Response
+import com.remax.visualnovel.event.model.OnAILiked
+import com.remax.visualnovel.extension.toast
+import com.remax.visualnovel.manager.login.LoginManager
+import com.remax.visualnovel.repository.api.base.BaseRepository
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserAIEvents
+import javax.inject.Inject
+
+/**
+ * Created by HJW on 2022/11/15
+ */
+class AIRepository @Inject constructor(private val aiService: AIService) : BaseRepository() {
+
+ suspend fun cardBind(aiId: String) = executeHttp {
+ aiService.cardBind(AIIDRequest(aiId))
+ }
+
+ suspend fun cardLiked() = executeHttp {
+ aiService.cardLiked()
+ }
+
+ suspend fun reportCard(request: CardRequest) = executeHttp(false) {
+ aiService.reportCard(request)
+ }
+
+ suspend fun getHomeCard(request: ClassificationRequest) = executeHttp {
+ aiService.getHomeCard(request)
+ }
+
+ suspend fun getHomeCardDetail(aiId: String) = executeHttp {
+ aiService.getHomeCardDetail(AIIDRequest(aiId))
+ }
+
+ suspend fun getClassificationList(request: ClassificationRequest) = executeHttp {
+ aiService.getClassificationList(request)
+ }
+
+ suspend fun getHeartbeatRank() = executeHttp {
+ aiService.getHeartbeatRank()
+ }
+
+ suspend fun getGiftRank() = executeHttp {
+ aiService.getGiftRank()
+ }
+
+ suspend fun getChatRank() = executeHttp {
+ aiService.getChatRank()
+ }
+
+ suspend fun getExploreInfo() = executeHttp {
+ aiService.getExploreInfo()
+ }
+
+
+ suspend fun unlockSecret(dto: AIIDRequest): Response {
+ return if (WalletManager.balance < 5000L) {
+ WalletManager.showChargeDialog()
+ ApiFailedResponse()
+ } else {
+ executeHttp {
+ aiService.unlockSecret(dto)
+ }
+ }.transformResult({
+ WalletManager.refreshWallet()
+ })
+ }
+
+ suspend fun unlockAlbum(dto: ChatAlbum): Response {
+ return if ((dto.unlockPrice ?: 0) > WalletManager.balance) {
+ WalletManager.showChargeDialog()
+ ApiFailedResponse()
+ } else {
+ executeHttp {
+ aiService.unlockAlbum(dto)
+ }
+ }.transformResult({
+ WalletManager.refreshWallet()
+ })
+ }
+
+ suspend fun setAlbumUnlockPrice(dto: AlbumDTO) = executeHttp {
+ aiService.setAlbumUnlockPrice(dto)
+ }
+
+ suspend fun setAILikeOrCancel(dto: AlbumDTO) = executeHttp {
+ aiService.setAILikeOrCancel(dto)
+ }.transformResult({
+ EventDefineOfUserAIEvents.onAILikedChanged().post(OnAILiked(dto.aiId,dto.liked))
+ })
+
+ suspend fun deleteAICharacter(aiId: String) = executeHttp {
+ aiService.deleteAICharacter(Character(aiId))
+ }.transformResult({
+ CommonApplicationProxy.application.toast(R.string.delete_succeed_toast)
+ EventDefineOfUserAIEvents.onAICharacterChanges().post(aiId)
+ })
+
+ suspend fun setAlbumDefault(aiId: String, albumId: Long) = executeHttp {
+ aiService.setAlbumDefault(AlbumDTO(aiId = aiId, albumId = albumId))
+ }.transformResult({
+ EventDefineOfUserAIEvents.onAIHomeImageChanges().post(aiId)
+ })
+
+ suspend fun editAIAvatar(request: AIHeadImgRequest) = executeHttp {
+ aiService.editAIAvatar(request)
+ }
+
+ suspend fun createOrEditAICharacter(request: Character) = executeHttp {
+ aiService.createOrEditAICharacter(request)
+ }.transformResult({
+ EventDefineOfUserAIEvents.onAICharacterChanges().post(it?.aiId)
+ })
+
+ suspend fun generateAICharacter(request: AIGenerate) = executeHttp {
+ aiService.generateAICharacter(request)
+ }
+
+ suspend fun getAICharacter(aiId: String) = executeHttp {
+ aiService.getAICharacter(Character(aiId = aiId))
+ }
+
+ suspend fun getAICharacterProfile(aiId: String) = executeHttp {
+ aiService.getAICharacterProfile(Character(aiId = aiId))
+ }
+
+ suspend fun getAICharacterStat(aiId: String) = executeHttp {
+ aiService.getAICharacterStat(Character(aiId = aiId))
+ }
+
+ suspend fun addAlbum(request: AlbumCreate) = executeHttp {
+ aiService.addAlbum(request)
+ }
+
+ suspend fun getAlbumCreateCount() = executeHttp {
+ aiService.getAlbumCreateCount()
+ }
+
+ suspend fun buyAlbumCreateCount(count: Int) = executeHttp {
+ aiService.buyAlbumCreateCount(SimpleCountDTO(count))
+ }.transformResult({
+ WalletManager.refreshWallet()
+ })
+
+ suspend fun addChatBackground(request: AlbumCreate) = executeHttp {
+ aiService.addChatBackground(request)
+ }
+
+ /**
+ * 喜欢或取消喜欢相片
+ */
+ suspend fun setLikeOrDislike(albumId: Long, isLike: Boolean) = executeHttp {
+ aiService.setLikeOrDislike(AlbumDTO(albumId, if (isLike) Album.CANCELED else Album.LIKED))
+ }
+
+ /**
+ * 删除相片
+ */
+ suspend fun deleteAlbum(albumId: Long) = executeHttp {
+ aiService.deleteAlbum(AlbumDTO(albumId, userId = LoginManager.user?.userId))
+ }
+
+ suspend fun getAlbumList(dto: QueryAlbumDTO) = executeHttp {
+ aiService.getAlbumList(dto)
+ }.transformResult({
+ it?.datas?.forEach { item ->
+ item.userId = dto.userId
+ item.aiId = dto.aiId
+ }
+ })
+
+ suspend fun getUserGiftList(aiId: String) = executeHttp {
+ aiService.getUserGiftList(QueryAlbumDTO(aiId, null, PageQuery.Page(1).apply { this.ps = 100 }))
+ }
+
+ suspend fun generateImageBatch(request: AIGenerateImage) = executeHttp {
+ aiService.generateImageBatch(request.apply {
+ hl = this.aiId != null
+ })
+ }.transformResult({
+ when(request.type){
+ /**
+ * 生成背景每次都要扣钱
+ */
+ AIGenerateImage.BACKGROUND -> WalletManager.refreshWallet()
+ }
+ })
+
+ /**
+ * AI一键生成-删除图片生成任务
+ */
+ suspend fun generateImageBatchDel(batchNo: String) = executeHttp {
+ aiService.generateImageBatchDel(AIGenerateImage(batchNo = batchNo))
+ }
+
+ /**
+ * AI一键生成-轮询查询图片生成结果
+ */
+ suspend fun generateImageBatchQuery(batchNo: String) = executeHttp {
+ aiService.generateImageBatchQuery(AIGenerateImage(batchNo = batchNo))
+ }
+
+ /**
+ * 获取相册 分页
+ */
+// suspend fun getAlbumList(userId: Int, custId: String?, id: Int, imgOrder: Int) = executeHttp {
+// userService.getAlbumList(QueryAlbumDTO(userId, custId, id, imgOrder))
+// }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ChatRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ChatRepository.kt
new file mode 100644
index 0000000..4c35748
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ChatRepository.kt
@@ -0,0 +1,133 @@
+package com.remax.visualnovel.repository.api
+
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfChatSettingEvents
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserAIEvents
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
+import com.remax.visualnovel.api.service.ChatService
+import com.remax.visualnovel.entity.request.AIFeedback
+import com.remax.visualnovel.entity.request.AIIDRequest
+import com.remax.visualnovel.entity.request.AIIsShowDTO
+import com.remax.visualnovel.entity.request.ChatAlbum
+import com.remax.visualnovel.entity.request.ChatSetting
+import com.remax.visualnovel.entity.request.HeartbeatBuy
+import com.remax.visualnovel.entity.request.RTCRequest
+import com.remax.visualnovel.entity.request.SearchPage
+import com.remax.visualnovel.entity.request.SimpleDataDTO
+import com.remax.visualnovel.entity.request.VoiceTTS
+import com.remax.visualnovel.entity.response.Character
+import com.remax.visualnovel.entity.response.ChatSet
+import com.remax.visualnovel.repository.api.base.BaseRepository
+import com.remax.visualnovel.ui.chat.message.events.model.ChatSetAutoPlayEvent
+import com.remax.visualnovel.ui.chat.message.events.model.ChatSetBackgroundEvent
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import javax.inject.Inject
+
+/**
+ * Created by HJW on 2022/11/15
+ */
+class ChatRepository @Inject constructor(private val chatService: ChatService) : BaseRepository() {
+
+ suspend fun sendDialogueMsg(aiId: String) = executeHttp {
+ chatService.sendDialogueMsg(AIIDRequest(aiId))
+ }
+
+ suspend fun viewAlbumImg(request: ChatAlbum) = executeHttp {
+ chatService.viewAlbumImg(request)
+ }
+
+ suspend fun getIMAICharacterProfile(aiId: String) = executeHttp {
+ chatService.getIMAICharacterProfile(Character(aiId = aiId))
+ }
+
+ suspend fun getMyFriends(request: SearchPage) = executeHttp {
+ chatService.getMyFriends(request)
+ }
+
+ suspend fun getMyFriendRank() = executeHttp {
+ chatService.getMyFriendRank()
+ }
+
+ suspend fun getPrompts(aiId: String) = executeHttp {
+ chatService.getPrompts(AIIDRequest(aiId))
+ }.transformResult({
+ WalletManager.refreshWallet()
+ })
+
+ suspend fun aiFeedback(request: AIFeedback) = executeHttp {
+ chatService.aiFeedback(request)
+ }
+
+ suspend fun getRTCToken(roomId: String) = executeHttp {
+ chatService.getRTCToken(RTCRequest(roomId))
+ }
+
+ suspend fun voiceChatOpt(request: RTCRequest) = executeHttp {
+ chatService.voiceChatOpt(request)
+ }
+
+ suspend fun getChatBackgroundList(aiId: String) = executeHttp {
+ chatService.getChatBackgroundList(AIIDRequest(aiId))
+ }
+
+ suspend fun getChatSetting(aiId: String?) = executeHttp {
+ chatService.getChatSetting(ChatSetting(aiId))
+ }
+
+ suspend fun setChatSetting(request: ChatSet) = executeHttp {
+ chatService.setChatSetting(request)
+ }.transformResult({
+ EventDefineOfUserEvents.onUserInfoChanged().post(null)
+ })
+
+ suspend fun setChatBubble(request: ChatSetting) = executeHttp {
+ chatService.setChatBubble(request)
+ }
+
+ suspend fun setChatModel(request: ChatSetting) = executeHttp {
+ chatService.setChatModel(request)
+ }
+
+ suspend fun setChatAutoPlay(request: ChatSetting) = executeHttp {
+ chatService.setChatAutoPlay(request)
+ }.transformResult({
+ EventDefineOfChatSettingEvents.settingChanged()
+ .post(ChatSetAutoPlayEvent(request.aiId ?: "", if (request.isAutoPlayVoice == true) 1 else 0))
+ })
+
+
+ suspend fun setChatBackground(request: ChatSetting) = executeHttp {
+ chatService.setChatBackground(request)
+ }.transformResult({
+ EventDefineOfChatSettingEvents.settingChanged()
+ .post(ChatSetBackgroundEvent(request.aiId ?: "", request.backgroundImg))
+ })
+
+ suspend fun deleteChatBackground(request: ChatSetting) = executeHttp {
+ chatService.deleteChatBackground(request)
+ }
+
+ suspend fun relationSwitch(request: AIIsShowDTO) = executeHttp {
+ chatService.relationSwitch(request)
+ }.transformResult({
+ EventDefineOfUserAIEvents.onAIHeartIsOpenChanged().post(request)
+ })
+
+ suspend fun voiceASR(aiId: String, url: String?) = executeHttp {
+ chatService.voiceASR(SimpleDataDTO(aiId = aiId, url = url))
+ }.transformResult({
+ WalletManager.refreshWallet()
+ })
+
+ suspend fun voiceTTS(request: VoiceTTS) = executeHttp {
+ chatService.voiceTTS(request)
+ }
+
+ suspend fun getHeartbeatLevel(aiId: String) = executeHttp {
+ chatService.getHeartbeatLevel(Character(aiId = aiId))
+ }
+
+ suspend fun buyHeartbeatVal(request: HeartbeatBuy) = executeHttp {
+ chatService.buyHeartbeatVal(request)
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt
index 3dacf11..f8a4b9c 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt
@@ -2,6 +2,7 @@ package com.remax.visualnovel.repository.api
import com.remax.visualnovel.api.service.DictService
+import com.remax.visualnovel.entity.request.AIIDRequest
import com.remax.visualnovel.repository.api.base.BaseRepository
import javax.inject.Inject
@@ -10,7 +11,7 @@ import javax.inject.Inject
*/
class DictRepository @Inject constructor(private val dictService: DictService) : BaseRepository() {
- /*suspend fun getChatBubbleList(aiId: String) = executeHttp {
+ suspend fun getChatBubbleList(aiId: String) = executeHttp {
dictService.getChatBubbleList(AIIDRequest(aiId))
}
@@ -20,6 +21,6 @@ class DictRepository @Inject constructor(private val dictService: DictService) :
suspend fun getGiftDict() = executeHttp(false) { dictService.getGiftDict() }
- suspend fun getAIChatModel() = executeHttp { dictService.getAIChatModel() }*/
+ suspend fun getAIChatModel() = executeHttp { dictService.getAIChatModel() }
}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt
index 5c68926..2101984 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt
@@ -1,8 +1,34 @@
package com.remax.visualnovel.repository.api
+import android.os.Handler
+import android.os.Looper
+import com.google.gson.Gson
+import com.netease.nimlib.sdk.NIMClient
+import com.netease.nimlib.sdk.v2.conversation.V2NIMConversationService
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageListener
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageService
+import com.netease.nimlib.sdk.v2.message.enums.V2NIMMessageQueryDirection
+import com.netease.nimlib.sdk.v2.message.option.V2NIMCloudMessageListOption
import com.remax.visualnovel.api.service.MessageService
+import com.remax.visualnovel.entity.imbean.IMMessageWrapper
+import com.remax.visualnovel.entity.request.AIListRequest
+import com.remax.visualnovel.entity.request.Gift
+import com.remax.visualnovel.entity.request.PageQuery
+import com.remax.visualnovel.entity.request.SendGift
+import com.remax.visualnovel.entity.response.Character
+import com.remax.visualnovel.entity.response.base.ApiFailedResponse
+import com.remax.visualnovel.entity.response.base.Response
+import com.remax.visualnovel.extension.resumeWithActive
+import com.remax.visualnovel.manager.login.LoginManager
+import com.remax.visualnovel.manager.nim.FetchResult
+import com.remax.visualnovel.manager.nim.LoadStatus
+import com.remax.visualnovel.manager.nim.NimManager
import com.remax.visualnovel.repository.api.base.BaseRepository
+import com.remax.visualnovel.repository.ext.convertMessage
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
@@ -11,5 +37,135 @@ import javax.inject.Inject
*/
class MessageRepository @Inject constructor(private val messageService: MessageService) : BaseRepository() {
+ private val v2NIMConversationService by lazy {
+ NIMClient.getService(V2NIMConversationService::class.java)
+ }
+
+ /**
+ * 批量删除会话
+ */
+ fun deleteConversationListByIds(ids: List, callback: () -> Unit) {
+ v2NIMConversationService.deleteConversationListByIds(ids, true, {
+ NimManager.log("删除会话成功 ${Gson().toJson(it)}")
+ callback.invoke()
+ }) {
+ NimManager.log("删除会话失败 ${Gson().toJson(it)}")
+ }
+ }
+
+ suspend fun deleteConversation(aiIdList: List) = executeHttp {
+ messageService.deleteConversation(AIListRequest(aiIdList))
+ }
+
+ private val messageFetchResult = FetchResult>(LoadStatus.Finish)
+
+ /**
+ * 分页查询历史消息
+ */
+ suspend fun getMessageList(conversationId: String, anchorMessage: V2NIMMessage? = null, character: Character?) =
+ suspendCancellableCoroutine { coroutine ->
+ coroutine.invokeOnCancellation {
+ coroutine.resumeWithActive(messageFetchResult)
+ }
+ val optionBuilder = V2NIMCloudMessageListOption.V2NIMCloudMessageListOptionBuilder
+ .builder(conversationId)
+ .withDirection(V2NIMMessageQueryDirection.V2NIM_QUERY_DIRECTION_DESC)
+
+ if (anchorMessage != null) {
+ optionBuilder.withAnchorMessage(anchorMessage)
+ optionBuilder.withEndTime(anchorMessage.createTime)
+ }
+
+ val option = optionBuilder.build()
+ // 此处避免在获取 anchor 消息后被之前消息添加导致ui移位,因此将 anchor 之前消息请求添加到后续的主线程事件队列中
+ Handler(Looper.getMainLooper())
+ .post {
+ // 调用接口
+ v2MessageService.getCloudMessageList(option, { result ->
+ NimManager.log("查询云端消息成功")
+ val messages = result?.messages
+ val anchorMessage = result.anchorMessage
+ NimManager.log("获取到 " + messages?.size + " 条消息")
+
+ val loadStatus = if (messages.isNullOrEmpty()) LoadStatus.Finish else LoadStatus.Success
+ messageFetchResult.loadStatus = loadStatus
+ messageFetchResult.data = messages?.convertMessage(anchorMessage != null, character)
+ messageFetchResult.extraInfo = anchorMessage
+
+ if (anchorMessage != null) {
+ NimManager.log("还有更多消息,下次查询锚点: " + anchorMessage.messageClientId)
+ } else {
+ NimManager.log("没有更多消息了")
+ }
+
+ coroutine.resumeWithActive(messageFetchResult)
+ }) { error ->
+ NimManager.log("查询云端消息失败, code: " + error.code + ", message: " + error.desc)
+ messageFetchResult.setError(error.code, error.desc)
+ messageFetchResult.data = null
+ messageFetchResult.extraInfo = null
+
+ coroutine.resumeWithActive(messageFetchResult)
+ }
+ }
+ }
+
+ private val v2MessageService by lazy {
+ NIMClient.getService(V2NIMMessageService::class.java)
+ }
+
+ /**
+ * 添加消息监听
+ */
+ fun setMessageListener(register: Boolean, listener: V2NIMMessageListener) {
+ if (register) {
+ v2MessageService.addMessageListener(listener)
+ } else {
+ v2MessageService.removeMessageListener(listener)
+ }
+ }
+
+ /**
+ * 接收方消息已读
+ */
+ fun sendP2PMessageReceipt(v2Message: V2NIMMessage) {
+ v2MessageService.sendP2PMessageReceipt(v2Message, {
+ NimManager.log("接收方消息已读: $v2Message")
+ }) {
+ NimManager.log("接收方消息已读失败 ${it.code} -- ${it.desc}")
+ }
+ }
+
+ fun sendMessage(v2Message: V2NIMMessage, accountId: String?, errorCallback: (Int) -> Unit) {
+ NimManager.v2SendMessage(v2Message, accountId, errorCallback)
+ }
+
+ suspend fun sendGift(dto: SendGift, gift: Gift): Response {
+ val totalPrice = gift.price * dto.num
+ return if (totalPrice > WalletManager.balance) {
+ WalletManager.showChargeDialog()
+ ApiFailedResponse()
+ } else {
+ executeHttp {
+ messageService.sendGift(dto)
+ }.transformResult({
+ WalletManager.refreshWallet()
+ })
+ }
+ }
+
+ suspend fun getMessageStat() = executeHttp(false) {
+ messageService.getMessageStat()
+ }.transformResult({
+ LoginManager.contactUnreadCount = it?.unRead ?: 0
+ })
+
+ /**
+ * 系统通知列表
+ */
+ suspend fun getMessageList(pn: Int) = executeHttp {
+ messageService.getMessageList(PageQuery(pn))
+ }
+
}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/OssRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/OssRepository.kt
new file mode 100644
index 0000000..000a45a
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/OssRepository.kt
@@ -0,0 +1,43 @@
+package com.remax.visualnovel.repository.api
+
+import com.remax.visualnovel.api.service.OssService
+import com.remax.visualnovel.entity.request.ImgCheckDTO
+import com.remax.visualnovel.entity.request.S3TypeDTO
+import com.remax.visualnovel.entity.request.SimpleContentDTO
+import com.remax.visualnovel.entity.response.BucketBean
+import com.remax.visualnovel.entity.response.base.Response
+import com.remax.visualnovel.repository.api.base.BaseRepository
+import javax.inject.Inject
+
+/**
+ * Created by HJW on 2022/10/27
+ *
+ * 应用相关
+ */
+class OssRepository @Inject constructor(
+ private val ossService: OssService
+) : BaseRepository() {
+
+ /**
+ * 获取aws s3 bucket信息
+ */
+ suspend fun getS3Bucket(ossType: String, postfix: String): Response =
+ executeHttp {
+ val bucketDTO = S3TypeDTO(ossType, postfix)
+ ossService.getS3Bucket(bucketDTO)
+ }
+
+ /**
+ * 图片鉴黄
+ */
+ suspend fun checkS3Img(imgCheckDTO: ImgCheckDTO): Response =
+ executeHttp {
+ ossService.checkS3Img(imgCheckDTO = imgCheckDTO)
+ }
+
+ suspend fun checkText(content: String): Response =
+ executeHttp {
+ ossService.checkText(simpleContentDTO = SimpleContentDTO(content))
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/PayRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/PayRepository.kt
new file mode 100644
index 0000000..7937daf
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/PayRepository.kt
@@ -0,0 +1,86 @@
+package com.remax.visualnovel.repository.api
+
+import com.remax.visualnovel.api.service.PayService
+import com.remax.visualnovel.entity.request.ChargeOrderDTO
+import com.remax.visualnovel.entity.request.ChargeProduct
+import com.remax.visualnovel.entity.request.ChargeProductInfo
+import com.remax.visualnovel.entity.request.SearchPage
+import com.remax.visualnovel.entity.request.ValidateTransactionDTO
+import com.remax.visualnovel.entity.response.UserSubInfo
+import com.remax.visualnovel.entity.response.Wallet
+import com.remax.visualnovel.entity.response.base.Response
+import com.remax.visualnovel.repository.api.base.BaseRepository
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents
+import javax.inject.Inject
+
+/**
+ * Created by HJW on 2022/11/8
+ *
+ * 支付相关
+ */
+class PayRepository @Inject constructor(
+ private val accountService: PayService,
+) : BaseRepository() {
+
+ suspend fun getVipPrivilegeList() = executeHttp {
+ accountService.getVipPrivilegeList()
+ }
+
+ suspend fun getTransactionList(request: SearchPage) = executeHttp {
+ accountService.getTransactionList(request)
+ }
+
+ /**
+ * 获取我的钱包
+ */
+ suspend fun getMyWallet(): Response = executeHttp(false) {
+ accountService.getMyWallet()
+ }.transformResult({
+ EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().post(it)
+ })
+
+ /**
+ * 验证支付是否成功
+ */
+ suspend fun validateTranslation(dto: ValidateTransactionDTO): Response =
+ executeHttp {
+ accountService.validateTranslation(dto = dto)
+ }
+
+ /**
+ * 获取充值产品
+ */
+ suspend fun getProducts(): Response =
+ executeHttp(false) {
+ accountService.getChargeProducts()
+ }
+
+ /**
+ * 创建一个google pay订单
+ */
+ suspend fun createOrder(product: ChargeProduct) = executeHttp {
+ accountService.createOrder(dto = ChargeOrderDTO(product.chargeAmount, product.productId))
+ }
+
+ /**
+ * 获取vip订阅价格列表
+ */
+ suspend fun getSubPriceList() = executeHttp(false) {
+ accountService.getSubPriceList()
+ }
+
+ /**
+ * 验证订阅是否成功
+ */
+ suspend fun uploadGoogleReceipt(dto: ValidateTransactionDTO): Response = executeHttp {
+ accountService.uploadGoogleReceipt(dto = dto)
+ }
+
+ /**
+ * 订阅/升级VIP前查询订阅信息
+ */
+ suspend fun checkSubInfo(): Response = executeHttp {
+ accountService.checkSubInfo()
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/ext/MessageExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/ext/MessageExt.kt
new file mode 100644
index 0000000..387b85e
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/ext/MessageExt.kt
@@ -0,0 +1,149 @@
+package com.remax.visualnovel.repository.ext
+
+import com.remax.visualnovel.entity.imbean.IMAIInMessage
+import com.remax.visualnovel.entity.imbean.IMBaseInfoMessage
+import com.remax.visualnovel.entity.imbean.IMCallMessage
+import com.remax.visualnovel.entity.imbean.IMGiftMessage
+import com.remax.visualnovel.entity.imbean.IMInImageMessage
+import com.remax.visualnovel.entity.imbean.IMLevelMessage
+import com.remax.visualnovel.entity.imbean.IMMessageWrapper
+import com.remax.visualnovel.entity.imbean.IMOutImageMessage
+import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData
+import com.remax.visualnovel.entity.imbean.raw.CustomCallData
+import com.remax.visualnovel.entity.imbean.raw.CustomGiftData
+import com.remax.visualnovel.entity.imbean.raw.CustomLevelChangeData
+import com.remax.visualnovel.entity.imbean.raw.CustomRawData
+import com.remax.visualnovel.entity.imbean.voice.IMVoice
+import com.remax.visualnovel.entity.request.SimpleTypeDTO
+import com.remax.visualnovel.entity.response.Character
+import com.remax.visualnovel.extension.convertFromJson
+import com.remax.visualnovel.manager.nim.FetchResult
+import com.remax.visualnovel.manager.nim.NimManager
+import com.remax.visualnovel.ui.chat.message.call.manager.RTCManager
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageCreator
+import com.netease.nimlib.sdk.v2.message.enums.V2NIMMessageSendingState
+import com.netease.nimlib.sdk.v2.message.enums.V2NIMMessageType
+
+/**
+ * Created by HJW on 2025/8/26
+ */
+
+
+/**
+ * 转化一下消息类
+ * 适配一下多布局
+ */
+fun List?.convertMessage(hasMore: Boolean, character: Character?): List {
+ val result = this?.map { v2NIMMessage -> v2NIMMessage.convertMessage(true) }
+ ?.filter { messageWrapper -> messageWrapper?.fetchType != FetchResult.FetchType.Remind }
+ ?.toMutableList()
+ if (!hasMore) {
+ result?.add(0, IMBaseInfoMessage(character))
+ /**
+ * 没有聊过天需要添加开场白
+ */
+ if (character?.isDelChatted != true) {
+ val prologueMsg = V2NIMMessageCreator.createTextMessage(character?.dialoguePrologue)
+ val prologue = IMAIInMessage(prologueMsg, IMVoice(prologueMsg.messageClientId ?: ""))
+ result?.add(0, prologue)
+ }
+ }
+ return result?.filterNotNull() ?: emptyList()
+}
+
+/**
+ * @param fromHistory 是否从消息历史解析
+ */
+fun V2NIMMessage.convertMessage(fromHistory:Boolean = false): IMMessageWrapper? {
+ var aiIsSending = true
+ var fetchType = when {
+ // 收到的消息总是展示
+ !this.isSelf -> FetchResult.FetchType.Add
+ // 自己发送的消息先loading
+ this.sendingState == V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_SENDING -> FetchResult.FetchType.Add
+ // AI只回复发送成功的
+ else -> {
+ aiIsSending = this.sendingState == V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
+ FetchResult.FetchType.Update
+ }
+ }
+ NimManager.log("消息转换 messageServerId:${this.messageServerId} text:${this.text} attachment.raw:${this.attachment?.raw} serverExtension:${this.serverExtension}")
+ val res = when (messageType) {
+ V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT -> {
+ if (isSelf) IMMessageWrapper(this)
+ else IMAIInMessage(this, IMVoice(this.messageServerId.toString()))
+ }
+
+ V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM -> {
+ val type = attachment?.raw?.convertFromJson()?.type?.uppercase()
+ when (type) {
+ /**
+ * 用户发送给AI的图片消息
+ */
+ CustomRawData.IMAGE -> {
+ if (isSelf) {
+ IMOutImageMessage(this, attachment?.raw?.convertFromJson())
+ } else {
+ IMInImageMessage(this, attachment?.raw?.convertFromJson())
+ }
+ }
+
+ /**
+ * 用户发送给AI的礼物消息
+ */
+ CustomRawData.GIFT -> {
+ if (isSelf) {
+ IMGiftMessage(this, attachment?.raw?.convertFromJson())
+ } else null
+ }
+
+ /**
+ * 心动等级变化
+ */
+ CustomRawData.HEARTBEAT_LEVEL_UP, CustomRawData.HEARTBEAT_LEVEL_DOWN -> {
+ fetchType = FetchResult.FetchType.Remind
+ aiIsSending = false
+ val msg = IMLevelMessage(this, attachment?.raw?.convertFromJson())
+ RTCManager.sendIMLevelMessage(msg)
+ msg
+ }
+
+ CustomRawData.VOICE_CHAT_EMOTION_SCORE -> {
+ aiIsSending = false
+ fetchType = FetchResult.FetchType.Remind
+ null
+ }
+
+ CustomRawData.INSUFFICIENT_BALANCE -> {
+ aiIsSending = false
+ fetchType = FetchResult.FetchType.Remind
+ WalletManager.refreshWallet()
+ if (!fromHistory){
+ RTCManager.balanceInsufficient()
+ }
+ null
+ }
+
+ /**
+ * 拨打语音电话
+ * 语音通话消息不需要回话
+ */
+ CustomRawData.CALL -> {
+ aiIsSending = false
+ IMCallMessage(this, attachment?.raw?.convertFromJson())
+ }
+
+ else -> null
+ }
+ }
+
+ else -> null
+ }
+
+ return res?.apply {
+ this.fetchType = fetchType
+ this.aiIsSending = aiIsSending
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/ChatActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/ChatActivity.kt
deleted file mode 100644
index a55f1b0..0000000
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/ChatActivity.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.remax.visualnovel.ui.Chat
-
-
-import androidx.activity.viewModels
-import androidx.lifecycle.Observer
-import com.alibaba.android.arouter.facade.annotation.Route
-import com.alibaba.android.arouter.launcher.ARouter
-import com.remax.visualnovel.app.base.BaseBindingActivity
-import com.remax.visualnovel.utils.Routers
-import com.remax.visualnovel.utils.StatusBarUtils
-import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
-import com.remax.visualnovel.R
-import com.remax.visualnovel.databinding.ActivityActorChatBinding
-import com.remax.visualnovel.event.model.OnLoginEvent
-import com.remax.visualnovel.extension.launchWithRequest
-import com.remax.visualnovel.ui.main.MainViewModel
-import dagger.hilt.android.AndroidEntryPoint
-
-
-
-@AndroidEntryPoint
-@Route(path = Routers.CHAT)
-class ChatActivity : BaseBindingActivity() {
-
- private val mViewModel by viewModels()
-
- override fun initView() {
- ARouter.getInstance().inject(this)
- //setToolbar(R.string.setting)
-
- StatusBarUtils.setStatusBarAndNavBarIsLight(this, false)
- StatusBarUtils.setTransparent(this)
-
- with(binding) {
-
- }
- }
-
- override fun initData() {
-
- }
-
- private val loginObserver = Observer {
- launchWithRequest({
- //TODO - business handling for login events
- if (it?.isLogin() == true) {
-
- } else {
-
- }
- })
- }
-
- override fun onDestroy() {
- super.onDestroy()
- EventDefineOfUserEvents.onLoginStatusChanged().removeObserver(loginObserver)
- }
-
-
- companion object {
- const val ACTOR_ID = "ACTOR_ID"
-
- fun start(actorId: Int) {
- ARouter.getInstance()
- .build(Routers.CHAT)
- .withInt(ACTOR_ID, actorId)
- .navigation()
- }
-
- }
-
-
-}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatActivity.kt
new file mode 100644
index 0000000..c34228b
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatActivity.kt
@@ -0,0 +1,255 @@
+package com.remax.visualnovel.ui.chat
+
+
+import androidx.activity.viewModels
+import androidx.lifecycle.Observer
+import androidx.lifecycle.lifecycleScope
+import com.alibaba.android.arouter.facade.annotation.Route
+import com.alibaba.android.arouter.launcher.ARouter
+import com.hjq.permissions.OnPermissionCallback
+import com.hjq.permissions.XXPermissions
+import com.hjq.permissions.permission.PermissionLists
+import com.hjq.permissions.permission.base.IPermission
+import com.remax.visualnovel.app.base.BaseBindingActivity
+import com.remax.visualnovel.utils.Routers
+import com.remax.visualnovel.utils.StatusBarUtils
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents
+import com.remax.visualnovel.R
+import com.remax.visualnovel.databinding.ActivityActorChatBinding
+import com.remax.visualnovel.entity.request.ChatSetting
+import com.remax.visualnovel.event.model.OnLoginEvent
+import com.remax.visualnovel.extension.countDownCoroutines
+import com.remax.visualnovel.extension.launchAndLoadingCollect
+import com.remax.visualnovel.extension.launchWithRequest
+import com.remax.visualnovel.extension.toast
+import com.remax.visualnovel.manager.nim.NimManager
+import com.remax.visualnovel.ui.chat.setting.model.ChatModelDialog
+import com.remax.visualnovel.ui.chat.ui.HoldToTalkDialog
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.remax.visualnovel.utils.RecordHelper
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Job
+import timber.log.Timber
+import kotlin.getValue
+
+
+@AndroidEntryPoint
+@Route(path = Routers.CHAT)
+class ChatActivity : BaseBindingActivity() {
+
+ private val chatViewModel by viewModels()
+ private val mRecordAssist = RecordAssist()
+
+
+
+
+ override fun initView() {
+ ARouter.getInstance().inject(this)
+ //setToolbar(R.string.setting)
+
+ StatusBarUtils.setStatusBarAndNavBarIsLight(this, false)
+ StatusBarUtils.setTransparent(this)
+
+ with(binding) {
+ initInputPanelEvents()
+ }
+ }
+
+ override fun initData() {
+
+ }
+
+ private val loginObserver = Observer {
+ launchWithRequest({
+ //TODO - business handling for login events
+ if (it?.isLogin() == true) {
+
+ } else {
+
+ }
+ })
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ EventDefineOfUserEvents.onLoginStatusChanged().removeObserver(loginObserver)
+ mRecordAssist.stopTalk()
+ }
+
+
+ /**
+ * 处理发送消息的错误码
+ */
+ private val sendMsgErrorCallback: (Int) -> Unit = { code ->
+ when (code) {
+ NimManager.SEND_IM_INSUFFICIENT_BALANCE -> {
+ showSelectModelDialog()
+ }
+ }
+ }
+
+ private fun initInputPanelEvents() {
+ with(binding.inputPanel) {
+ holdToTalk({
+ mRecordAssist.startRecording()
+ }) {
+ mRecordAssist.stopTalk()
+ }
+ }
+ }
+
+ /**
+ * 检查麦克风权限
+ */
+ private fun checkRecordAudioPermission(onGranted: () -> Unit, allowGranted: (() -> Unit)? = null) {
+ val permission = PermissionLists.getRecordAudioPermission()
+ if (XXPermissions.isGrantedPermission(this, permission)) {
+ onGranted.invoke()
+ } else {
+ XXPermissions.with(this).permission(permission)
+ .request(object : OnPermissionCallback {
+ override fun onDenied(permissions: MutableList, doNotAskAgain: Boolean) {
+ this@ChatActivity.toast(R.string.no_permission)
+ if (doNotAskAgain) {
+ // 如果是被永久拒绝就跳转到应用权限系统设置页面
+ XXPermissions.startPermissionActivity(this@ChatActivity, permissions)
+ }
+ }
+
+ override fun onGranted(permissions: MutableList, allGranted: Boolean) {
+ if (!allGranted) {
+ this@ChatActivity.toast(getString(R.string.no_permission))
+ } else {
+ allowGranted?.invoke()
+ }
+ }
+ })
+ }
+ }
+
+
+ private fun showSelectModelDialog() {
+ with(chatViewModel) {
+ fun createModelDialog() {
+ val modelDialog = ChatModelDialog(this@ChatActivity)
+
+ modelDialog.build(chatModels ?: emptyList(), chatSet?.modelCode, {
+
+ }) { model ->
+ launchAndLoadingCollect({
+ setChatModel(
+ ChatSetting(aiId, model.code)
+ )
+ }) {
+ onSuccess = { res ->
+ modelDialog.dismiss()
+ }
+ }
+ }
+ modelDialog.show()
+ }
+
+ if (chatModels.isNullOrEmpty()) {
+ launchAndLoadingCollect({
+ chatViewModel.getChatModels()
+ }) {
+ onSuccess = {
+ createModelDialog()
+ }
+ }
+ } else {
+ createModelDialog()
+ }
+ }
+ }
+
+
+ companion object {
+ const val ACTOR_ID = "ACTOR_ID"
+
+ fun start(actorId: Int) {
+ ARouter.getInstance()
+ .build(Routers.CHAT)
+ .withInt(ACTOR_ID, actorId)
+ .navigation()
+ }
+
+ }
+
+
+ inner class RecordAssist {
+
+ private var recordJob: Job? = null
+
+ private val recordHelper by lazy {
+ RecordHelper()
+ }
+
+ private val holdToTalkDialog by lazy {
+ HoldToTalkDialog(this@ChatActivity).build()
+ }
+
+ /**
+ * 开始按住说话
+ */
+ fun startRecording() {
+ // TODO - check bill count
+ checkRecordAudioPermission({ startTalk() })
+ /*when {
+ // 没钱就去充值
+ WalletManager.balance < 1000L -> WalletManager.showChargeDialog()
+ // 检查权限
+ else -> {
+ checkRecordAudioPermission({ startTalk() })
+ }
+
+ }*/
+ }
+
+
+ private fun startTalk() {
+ val maxTalkTime = 60
+ val minTalkTime = 1
+
+ var recordingProgress = 0
+
+ holdToTalkDialog.show()
+ recordHelper.startRecording(this@ChatActivity, {
+ recordJob = lifecycleScope.countDownCoroutines(maxTalkTime, {
+ Timber.i("startRecording countDownCoroutines: %d", it)
+ recordingProgress = maxTalkTime - it
+ }, {
+ stopTalk()
+ })
+
+ }, {
+ if (recordingProgress >= minTalkTime) {
+ Timber.i("startRecording onStop: ${recordHelper.getFilename()}")
+
+ launchAndLoadingCollect({
+ chatViewModel.voiceASR(recordHelper.getFilename())
+ }) {
+ onSuccess = {
+ if (!it?.content.isNullOrBlank()) {
+ chatViewModel.sendMsg(it.content, errorCallback = sendMsgErrorCallback)
+ }
+ }
+ }
+
+ } else {
+ //录音最少1秒
+ showToast(R.string.min_voice_time)
+ }
+ })
+ }
+
+
+ fun stopTalk() {
+ holdToTalkDialog.dismiss()
+ recordJob?.cancel()
+ recordHelper.stopRecording()
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/ChatEditView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatEditView.kt
similarity index 93%
rename from VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/ChatEditView.kt
rename to VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatEditView.kt
index ff1d44a..95e2bf8 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/ChatEditView.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatEditView.kt
@@ -1,4 +1,4 @@
-package com.remax.visualnovel.ui.Chat
+package com.remax.visualnovel.ui.chat
import android.content.Context
import android.util.AttributeSet
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatViewModel.kt
new file mode 100644
index 0000000..ef9eb50
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ChatViewModel.kt
@@ -0,0 +1,357 @@
+package com.remax.visualnovel.ui.chat
+
+/**
+ * Created by HJW on 2025/8/13
+ */
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import com.remax.visualnovel.app.viewmodel.base.OssViewModel
+import com.remax.visualnovel.entity.imbean.IMAIInMessage
+import com.remax.visualnovel.entity.imbean.IMMessageWrapper
+import com.remax.visualnovel.entity.imbean.raw.CustomRawData
+import com.remax.visualnovel.entity.imbean.raw.CustomScoreData
+import com.remax.visualnovel.entity.request.AIFeedback
+import com.remax.visualnovel.entity.request.AlbumDTO
+import com.remax.visualnovel.entity.request.ChatAlbum
+import com.remax.visualnovel.entity.request.ChatSetting
+import com.remax.visualnovel.entity.request.Gift
+import com.remax.visualnovel.entity.request.S3TypeDTO
+import com.remax.visualnovel.entity.request.SendGift
+import com.remax.visualnovel.entity.response.Album
+import com.remax.visualnovel.entity.response.BucketBean
+import com.remax.visualnovel.entity.response.Character
+import com.remax.visualnovel.entity.response.ChatModel
+import com.remax.visualnovel.entity.response.ChatSet
+import com.remax.visualnovel.entity.response.HeartbeatLevelEnum
+import com.remax.visualnovel.entity.response.VoiceASR
+import com.remax.visualnovel.entity.response.base.ApiEmptyResponse
+import com.remax.visualnovel.entity.response.base.ApiFailedResponse
+import com.remax.visualnovel.entity.response.base.Response
+import com.remax.visualnovel.extension.convertFromJson
+import com.remax.visualnovel.manager.nim.FetchResult
+import com.remax.visualnovel.manager.nim.LoadStatus
+import com.remax.visualnovel.manager.nim.NimManager
+import com.remax.visualnovel.repository.api.AIRepository
+import com.remax.visualnovel.repository.api.ChatRepository
+import com.remax.visualnovel.repository.api.DictRepository
+import com.remax.visualnovel.repository.api.MessageRepository
+import com.remax.visualnovel.repository.ext.convertMessage
+import com.remax.visualnovel.ui.chat.message.call.manager.RTCManager
+import com.google.gson.Gson
+import com.netease.nimlib.sdk.msg.constant.SessionTypeEnum
+import com.netease.nimlib.sdk.v2.message.V2NIMClearHistoryNotification
+import com.netease.nimlib.sdk.v2.message.V2NIMMessage
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageCreator
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageDeletedNotification
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageListener
+import com.netease.nimlib.sdk.v2.message.V2NIMMessagePinNotification
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageQuickCommentNotification
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageRevokeNotification
+import com.netease.nimlib.sdk.v2.message.V2NIMP2PMessageReadReceipt
+import com.netease.nimlib.sdk.v2.message.V2NIMTeamMessageReadReceipt
+import com.netease.nimlib.sdk.v2.utils.V2NIMConversationIdUtil
+import com.remax.visualnovel.manager.gift.GiftManager
+import com.remax.visualnovel.ui.chat.message.detail.flirting.FlirtingLevelActivity
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+
+@HiltViewModel
+class ChatViewModel @Inject constructor(
+ private val chatRepository: ChatRepository,
+ private val dictRepository: DictRepository,
+ private val messageRepository: MessageRepository,
+ private val aiRepository: AIRepository,
+) : OssViewModel() {
+
+ var aiId: String = ""
+
+ var character: Character? = null
+ private set
+
+ val conversationId: String
+ get() = V2NIMConversationIdUtil.conversationId(character?.nimAccountId, SessionTypeEnum.P2P) ?: ""
+
+ private val _aiBaseInfoFlow = MutableLiveData>()
+ val aiBaseInfoFlow = _aiBaseInfoFlow
+
+ fun checkHeartbeatLevel(targetLevel: HeartbeatLevelEnum, checkSuccess: () -> Unit) {
+ val currLevel = character?.aiUserHeartbeatRelation?.currHeartbeatEnum
+ if ((currLevel?.level ?: 0) >= targetLevel.level) {
+ checkSuccess()
+ } else {
+ FlirtingLevelActivity.start(aiId)
+ }
+ }
+
+ suspend fun refreshAIBaseInfo(enterPage: Boolean) = chatRepository.getIMAICharacterProfile(aiId).transformResult({
+ character = it
+ NimManager.getUserList(listOf(character?.nimAccountId))
+ _aiBaseInfoFlow.value = Pair(enterPage, it)
+ getChatSetting()
+ getChatModels()
+ })
+
+ var chatModels: List? = null
+ private set
+
+ suspend fun getChatModels() = dictRepository.getAIChatModel().transformResult({
+ chatModels = it
+ })
+
+ suspend fun setChatModel(request: ChatSetting) = chatRepository.setChatModel(request).transformResult({
+ getChatSetting()
+ })
+
+ var chatSet: ChatSet? = null
+ private set
+
+ suspend fun getChatSetting() = chatRepository.getChatSetting(aiId).transformResult({
+ chatSet = it
+ })
+
+
+ /**
+ * 云信发送消息,raw代表是自定义消息
+ */
+ fun sendMsg(msgContent: String, raw: CustomRawData? = null, errorCallback: (Int) -> Unit) {
+
+ val v2Message = if (raw != null)
+ V2NIMMessageCreator.createCustomMessage(msgContent, Gson().toJson(raw))
+ else
+ V2NIMMessageCreator.createTextMessage(msgContent)
+
+ messageRepository.sendMessage(v2Message, character?.nimAccountId) { errorCode ->
+ errorCallback(errorCode)
+ }
+ }
+
+ // 消息发送LiveData,本地发送消息通过该LiveData通知UI
+ private val _sendScoreLiveData = MutableLiveData()
+ val sendScoreLiveData: LiveData = _sendScoreLiveData
+
+ /**
+ * 接受方添加消息接收回调
+ */
+ private val messageListener = object : V2NIMMessageListener {
+ /**
+ * 消息接收
+ */
+ override fun onReceiveMessages(messages: List) {
+ NimManager.log("消息接收回调 onReceiveMessages")
+ NimManager.clearUnreadCountByIds(conversationId)
+ messages.firstOrNull()?.let { message ->
+ if (message.conversationId == conversationId) {
+ /**
+ * message中的分数
+ * 本地实时加减一下
+ * AI发送的文本消息和语音通话时,都会有serverExtension:{"score":0.1}的计算
+ */
+ message.serverExtension?.convertFromJson()?.let {
+ _sendScoreLiveData.value = it.score
+ RTCManager.sendIMScoreMessage(it.score)
+ }
+
+ val messageRecFetchResult = FetchResult(LoadStatus.Success)
+ message.convertMessage()?.let { messageWrapper ->
+ if (messageWrapper.type == IMMessageWrapper.IN_TEXT_TYPE) {
+ /**
+ * 处理自动播放
+ */
+ if (character?.isAutoPlayVoice == 1) {
+ (messageWrapper as? IMAIInMessage)?.imVoice?.autoPlay = true
+ }
+ }
+ messageRecFetchResult.data = messageWrapper
+ messageRecFetchResult.type = messageWrapper.fetchType
+
+ messageRecFetchResult.typeIndex = -1
+ _sendMessageLiveData.value = messageRecFetchResult
+ }
+ }
+ }
+ }
+
+ override fun onReceiveP2PMessageReadReceipts(readReceipts: List?) {
+ }
+
+ override fun onReceiveTeamMessageReadReceipts(readReceipts: List?) {
+ }
+
+ override fun onMessageRevokeNotifications(revokeNotifications: List?) {
+ }
+
+ override fun onMessagePinNotification(pinNotification: V2NIMMessagePinNotification?) {
+ }
+
+ override fun onMessageQuickCommentNotification(quickCommentNotification: V2NIMMessageQuickCommentNotification?) {
+ }
+
+ override fun onMessageDeletedNotifications(messageDeletedNotifications: List?) {
+ }
+
+ override fun onClearHistoryNotifications(clearHistoryNotifications: List?) {
+ }
+
+ /**
+ * 本端发送消息状态回调
+ */
+ override fun onSendMessage(message: V2NIMMessage) {
+ val sendingState = message.sendingState
+ NimManager.log("本端发送消息 $message")
+ NimManager.log("本端发送消息状态 $sendingState")
+ /**
+ * 发消息时需要刷新一下调用推荐回复接口
+ */
+ refreshPrompts = true
+ if (message.conversationId == conversationId) {
+ postMessageSend(message)
+ }
+ }
+
+ override fun onReceiveMessagesModified(messages: List) {
+ NimManager.log("收到更新的消息 $messages")
+ messages.firstOrNull()?.let { message ->
+ NimManager.log("收到更新的消息 conversationId:${message.conversationId}")
+ if (message.conversationId == conversationId) {
+ val messageRecFetchResult = FetchResult(LoadStatus.Success)
+ messageRecFetchResult.data = message.convertMessage()
+ messageRecFetchResult.type = FetchResult.FetchType.Update
+ messageRecFetchResult.typeIndex = -1
+ _sendMessageLiveData.value = messageRecFetchResult
+ }
+ }
+ }
+ }
+
+ fun updateMsg(message: V2NIMMessage) {
+ val messageRecFetchResult = FetchResult(LoadStatus.Success)
+ messageRecFetchResult.data = message.convertMessage()
+ messageRecFetchResult.type = FetchResult.FetchType.Update
+ messageRecFetchResult.typeIndex = -1
+ _sendMessageLiveData.value = messageRecFetchResult
+ }
+
+ fun aiFeedback(request: AIFeedback) {
+ viewModelScope.launch {
+ chatRepository.aiFeedback(request)
+ }
+ }
+
+ // 消息发送LiveData,本地发送消息通过该LiveData通知UI
+ private val _sendMessageLiveData = MutableLiveData?>()
+ val sendMessageLiveData: LiveData?> = _sendMessageLiveData
+
+ private val sendMessageFetchResult by lazy {
+ FetchResult(LoadStatus.Finish)
+ }
+
+ // 同步发送消息
+ private fun postMessageSend(message: V2NIMMessage) {
+ message.convertMessage()?.let { messageWrapper ->
+ sendMessageFetchResult.loadStatus = LoadStatus.Success
+ sendMessageFetchResult.type = messageWrapper.fetchType
+ sendMessageFetchResult.data = messageWrapper
+ _sendMessageLiveData.value = sendMessageFetchResult
+ }
+ }
+
+ fun addMessageListener() {
+ messageRepository.setMessageListener(true, messageListener)
+ }
+
+ private var bucketBean: BucketBean? = null
+
+ override fun onStart() {
+ viewModelScope.launch {
+ getBucketToken("mp3", S3TypeDTO.SOUND_PATH).transformResult({
+ bucketBean = it
+ })
+ }
+ }
+
+ override fun onDestroy() {
+ messageRepository.setMessageListener(false, messageListener)
+ NimManager.clearUnreadCountByIds(conversationId)
+ GiftManager.initSelect()
+ }
+
+ private var anchorMessage: V2NIMMessage? = null
+
+ /**
+ * 获取历史消息
+ */
+ suspend fun getMessageList(isRefresh: Boolean): FetchResult> {
+ if (isRefresh) anchorMessage = null
+ val res = messageRepository.getMessageList(conversationId, anchorMessage, character)
+// if (isRefresh && res.data?.size == 1) {
+// sendDialogueMsg()
+// }
+ anchorMessage = res.extraInfo as? V2NIMMessage
+ return res
+ }
+
+ suspend fun voiceASR(filePath: String): Response {
+ ossUploadFile(filePath, S3TypeDTO.SOUND, isImg = false, token = bucketBean).transformResult({
+ return chatRepository.voiceASR(aiId, it?.urlPath)
+ }) {
+ return Response.createZipFailResponse(it)
+ }
+ return ApiEmptyResponse()
+ }
+
+ suspend fun sendGift(request: SendGift, gift: Gift): Response {
+ return messageRepository.sendGift(request, gift)
+ }
+
+ var refreshPrompts = true
+ private set
+
+ suspend fun getPrompts(): Response> {
+ return if (refreshPrompts) {
+ refreshPrompts = false
+ chatRepository.getPrompts(aiId).transformResult {
+ // 失败的话需要重新拉
+ refreshPrompts = true
+ }
+ } else {
+ ApiFailedResponse()
+ }
+ }
+
+ suspend fun viewAlbumImg(request: ChatAlbum) = chatRepository.viewAlbumImg(request)
+
+ suspend fun unlockAlbum(request: ChatAlbum) = aiRepository.unlockAlbum(request)
+
+ fun sendDialogueMsg() {
+ viewModelScope.launch {
+ chatRepository.sendDialogueMsg(aiId)
+ }
+ }
+
+ suspend fun changeLiked(): Response {
+ val isLiked = character?.liked == true
+ val request = AlbumDTO(
+ aiId = aiId,
+ likedStatus = if (isLiked) Album.LIKED else Album.CANCELED,
+ liked = isLiked
+ )
+ return aiRepository.setAILikeOrCancel(request).transformResult({
+ changeCharacterLiked(isLiked)
+ })
+ }
+
+ fun changeCharacterLiked(isLiked: Boolean) {
+ character?.apply {
+ liked = isLiked
+ likedNum = if (isLiked) {
+ likedNum?.plus(1)
+ } else {
+ likedNum?.minus(1)
+ }
+ }
+ }
+
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/InputPanel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/InputPanel.kt
similarity index 78%
rename from VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/InputPanel.kt
rename to VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/InputPanel.kt
index 093ce9e..c3c7624 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/InputPanel.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/InputPanel.kt
@@ -1,7 +1,9 @@
-package com.remax.visualnovel.ui.Chat
+package com.remax.visualnovel.ui.chat
+import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
+import android.view.MotionEvent
import android.widget.FrameLayout
import android.widget.Toast
import com.dylanc.viewbinding.nonreflection.inflate
@@ -79,4 +81,22 @@ class InputPanel @JvmOverloads constructor(context: Context, attrs: AttributeSet
}
+ @SuppressLint("ClickableViewAccessibility")
+ fun holdToTalk(callback: () -> Unit, cancelCallback: () -> Unit) {
+ binding.ivHold2talk.run {
+ setOnTouchListener { v, event ->
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ callback.invoke()
+ }
+
+ MotionEvent.ACTION_UP -> {
+ cancelCallback.invoke()
+ }
+ }
+ true
+ }
+ }
+ }
+
}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/PopMenuIconView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/PopMenuIconView.kt
similarity index 99%
rename from VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/PopMenuIconView.kt
rename to VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/PopMenuIconView.kt
index d0f6ed8..ef47ab4 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/Chat/PopMenuIconView.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/PopMenuIconView.kt
@@ -1,4 +1,4 @@
-package com.remax.visualnovel.ui.Chat
+package com.remax.visualnovel.ui.chat
import android.animation.Animator
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/BinaryMessageHandler.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/BinaryMessageHandler.kt
new file mode 100644
index 0000000..4a989e1
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/BinaryMessageHandler.kt
@@ -0,0 +1,89 @@
+package com.remax.visualnovel.ui.chat.message.call.manager
+import com.remax.visualnovel.extension.convertFromJson
+import java.nio.charset.StandardCharsets
+
+
+/**
+ * text 字幕文本。
+ *
+ * language 字幕语言。
+ *
+ * userId 字幕源的 ID。如果字幕源是人类用户,则此值为人类用户的 UserId。如果字幕源是 AI 代理,则此值为 AI 代理的 UserId。
+ *
+ * sequence 整数 字幕序列号。
+ *
+ * definite 布尔值 字幕是否为完整的句段。
+ * * true:是。
+ * * false:否。
+ *
+ * paragraph 布尔值 副标题是否为完整句子。
+ * * true:是。
+ * * false:否。
+ *
+ * roundId 整数 对话的回合 ID。
+ *
+ * 在不同的使用场景下,你可以根据definite、paragraph和sequence字段来决定如何处理字幕。
+ * 实时字幕显示:
+ * 如果paragraph=false且definite= false,则用较新的字幕(序列号更高)替换较旧的字幕。
+ * 如果paragraph=false且definite= true,则开始一个新句子并替换前一个句子。
+ * 如果paragraph= true,则表示一个完整句子的结束。此时继续解析并显示字幕将导致重复显示。
+ */
+data class SubtitleMsgData(
+ val definite: Boolean?,
+ val language: String?,
+ val paragraph: Boolean?,
+ val sequence: Int?,
+ val text: String?,
+ val userId: String?
+)
+
+
+data class SubtitleData(
+ val data: List?,
+)
+
+class BinaryMessageHandler() {
+ fun unpack(message: ByteArray, callback: (SubtitleMsgData) -> Unit): Boolean {
+ val kSubtitleHeaderSize = 8
+ if (message.size < kSubtitleHeaderSize) {
+ return false
+ }
+ // Magic number "subv"
+ val magic = ((message[0].toInt() and 0xFF shl 24) or
+ (message[1].toInt() and 0xFF shl 16) or
+ (message[2].toInt() and 0xFF shl 8) or
+ (message[3].toInt() and 0xFF)).toUInt()
+ if (magic != 0x73756276U) {
+ RTCManager.log("unpack magic != 0x73756276U")
+ return false
+ }
+
+ val length = ((message[4].toInt() and 0xFF shl 24) or
+ (message[5].toInt() and 0xFF shl 16) or
+ (message[6].toInt() and 0xFF shl 8) or
+ (message[7].toInt() and 0xFF)).toUInt()
+
+ if (message.size - kSubtitleHeaderSize != length.toInt()) {
+ RTCManager.log("unpack != length.toInt()")
+ return false
+ }
+
+ if (length > 0U) {
+ val subtitleBytes = message.copyOfRange(kSubtitleHeaderSize, message.size)
+ parseData(String(subtitleBytes, StandardCharsets.UTF_8), callback)
+ } else {
+ parseData("", callback)
+ }
+ return true
+ }
+
+ // Parse
+ private fun parseData(msg: String, callback: (SubtitleMsgData) -> Unit) {
+ val subtitles = msg.convertFromJson()
+ // 这里可以进一步处理 subtitles 列表,例如打印或存储
+ subtitles?.data?.forEach {
+ RTCManager.log("Parsed subtitles: $it")
+ callback.invoke(it)
+ }
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/RTCExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/RTCExt.kt
new file mode 100644
index 0000000..2feebf3
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/RTCExt.kt
@@ -0,0 +1,31 @@
+package com.remax.visualnovel.ui.chat.message.call.manager
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+
+/**
+ * 防抖函数,在指定时间内只执行最后一次调用
+ * @param delayMs 防抖时间(毫秒)
+ * @param scope 协程作用域
+ * @param action 要执行的操作
+ */
+fun debounce(
+ delayMs: Long = 0L,
+ scope: CoroutineScope,
+ action: (T1, T2?) -> Unit
+): (T1, T2?) -> Unit {
+ var debounceJob: Job? = null
+
+ return { p1, p2 ->
+ // 取消之前的任务
+ debounceJob?.cancel()
+ // 创建新的协程任务
+ debounceJob = scope.launch {
+ delay(delayMs)
+ action(p1, p2)
+ }
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/RTCManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/RTCManager.kt
new file mode 100644
index 0000000..a926328
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/call/manager/RTCManager.kt
@@ -0,0 +1,353 @@
+package com.remax.visualnovel.ui.chat.message.call.manager
+
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import com.remax.visualnovel.BuildConfig
+import com.remax.visualnovel.app.base.app.CommonApplicationProxy
+import com.remax.visualnovel.entity.imbean.IMLevelMessage
+import com.remax.visualnovel.entity.imbean.raw.CustomCallData
+import com.remax.visualnovel.entity.imbean.raw.CustomRawData
+import com.remax.visualnovel.entity.request.HeartbeatRelation
+import com.remax.visualnovel.manager.nim.NimManager
+import com.remax.visualnovel.utils.TimeUtils
+import com.google.gson.Gson
+import com.netease.nimlib.sdk.v2.message.V2NIMMessageCreator
+import com.ss.bytertc.engine.RTCRoom
+import com.ss.bytertc.engine.RTCRoomConfig
+import com.ss.bytertc.engine.RTCVideo
+import com.ss.bytertc.engine.UserInfo
+import com.ss.bytertc.engine.data.AudioPropertiesConfig
+import com.ss.bytertc.engine.data.LocalAudioPropertiesInfo
+import com.ss.bytertc.engine.data.LocalAudioStreamError
+import com.ss.bytertc.engine.data.LocalAudioStreamState
+import com.ss.bytertc.engine.data.RemoteAudioPropertiesInfo
+import com.ss.bytertc.engine.data.RemoteAudioState
+import com.ss.bytertc.engine.data.RemoteAudioStateChangeReason
+import com.ss.bytertc.engine.data.RemoteStreamKey
+import com.ss.bytertc.engine.data.StreamSycnInfoConfig
+import com.ss.bytertc.engine.handler.IRTCRoomEventHandler
+import com.ss.bytertc.engine.handler.IRTCVideoEventHandler
+import com.ss.bytertc.engine.type.AudioProfileType
+import com.ss.bytertc.engine.type.AudioScenarioType
+import com.ss.bytertc.engine.type.ChannelProfile
+import com.ss.bytertc.engine.type.MediaStreamType
+import com.ss.bytertc.engine.type.NetworkQualityStats
+import com.ss.bytertc.engine.utils.LogUtil
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.nio.ByteBuffer
+
+/**
+ * Created by HJW on 2025/8/25
+ */
+object RTCManager : DefaultLifecycleObserver {
+
+ private var mRTCVideo: RTCVideo? = null
+ private var mRTCRoom: RTCRoom? = null
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ leaveRoom()
+ destroyEngine()
+ }
+
+ fun log(content: String?) {
+ Timber.i("RTCManager $content")
+ }
+
+ var onAudioPropertiesReport: ((isLocal: Boolean, linearVolume: Int) -> Unit)? = null
+
+ private val mRTCVideoEventHandler = object : IRTCVideoEventHandler() {
+ override fun onLocalAudioPropertiesReport(audioPropertiesInfos: Array?) {
+ audioPropertiesInfos?.forEach {
+// log("onLocalAudioPropertiesReport audioPropertiesInfos:${it.toString()}")
+ /**
+ * 线性音量,与原始音量呈线性关系,数值越大,音量越大。取值范围是:[0,255]。
+ *
+ * [0, 25]: 近似无声
+ * [26, 75]: 低音量
+ * [76, 204]: 中音量
+ * [205, 255]: 高音量
+ */
+ val linearVolume = it?.audioPropertiesInfo?.linearVolume ?: 0
+ onAudioPropertiesReport?.invoke(true, linearVolume)
+ log(" 本地 在说话,音量是:$linearVolume")
+ }
+ }
+
+ override fun onAudioPlaybackDeviceTestVolume(volume: Int) {
+ log("onAudioPlaybackDeviceTestVolume volume:$volume")
+ }
+
+ override fun onRemoteAudioPropertiesReport(
+ audioPropertiesInfos: Array?,
+ totalRemoteVolume: Int
+ ) {
+ audioPropertiesInfos?.forEach {
+// log("onRemoteAudioPropertiesReport audioPropertiesInfos:${it.toString()}")
+ /**
+ * 线性音量,与原始音量呈线性关系,数值越大,音量越大。取值范围是:[0,255]。
+ *
+ * [0, 25]: 近似无声
+ * [26, 75]: 低音量
+ * [76, 204]: 中音量
+ * [205, 255]: 高音量
+ */
+ val linearVolume = it?.audioPropertiesInfo?.linearVolume ?: 0
+ onAudioPropertiesReport?.invoke(false, linearVolume)
+ log(" 远端 在说话,音量是:$linearVolume")
+ }
+ }
+
+ override fun onUserStartAudioCapture(roomId: String?, uid: String?) {
+ log("onUserStartAudioCapture roomId:$roomId uid:$uid")
+
+ }
+
+ override fun onUserStopAudioCapture(roomId: String?, uid: String?) {
+ log("onUserStopAudioCapture roomId:$roomId uid:$uid")
+ }
+
+ override fun onLocalAudioStateChanged(state: LocalAudioStreamState?, error: LocalAudioStreamError?) {
+ log("onLocalAudioStateChanged state:$state error:$error")
+ }
+
+ override fun onRemoteAudioStateChanged(
+ key: RemoteStreamKey?,
+ state: RemoteAudioState?,
+ reason: RemoteAudioStateChangeReason?
+ ) {
+ log("onRemoteAudioStateChanged state:$state reason:$reason")
+ }
+
+
+ override fun onStreamSyncInfoReceived(
+ streamKey: RemoteStreamKey?,
+ streamType: StreamSycnInfoConfig.SyncInfoStreamType?,
+ data: ByteBuffer?
+ ) {
+// log("onStreamSyncInfoReceived streamType:$streamType data:$data")
+ }
+
+ override fun onWarning(warn: Int) {
+ log("onWarning:$warn")
+ }
+
+ override fun onError(err: Int) {
+ log("onError:$err")
+ }
+
+ override fun onLoggerMessage(level: LogUtil.LogLevel?, msg: String?, throwable: Throwable?) {
+ log("onLoggerMessage level:$level msg:$msg throwable:$throwable")
+ }
+ }
+
+ var joinRoomCallback: (() -> Unit)? = null
+
+ var binaryMessageReceived: ((SubtitleMsgData) -> Unit)? = null
+
+ private val mRTCRoomEventHandler = object : IRTCRoomEventHandler() {
+
+ override fun onRoomStateChanged(roomId: String?, uid: String?, state: Int, extraInfo: String?) {
+ log("onRoomStateChanged: roomId=$roomId, uid=$uid, state=$state, extra=$extraInfo")
+ if (state == 0) {
+ joinRoomCallback?.invoke()
+ }
+ }
+
+ override fun onStreamPublishSuccess(uid: String?, isScreen: Boolean) {
+ log("onRoomStateChanged: uid=$uid, isScreen=$isScreen")
+ }
+
+ override fun onStreamStateChanged(roomId: String?, uid: String?, state: Int, extraInfo: String?) {
+ log("onStreamStateChanged:roomId=$roomId, uid=$uid, state=$state, extraInfo=$extraInfo")
+ }
+
+ override fun onRoomBinaryMessageReceived(uid: String, message: ByteBuffer?) {
+ log("onRoomBinaryMessageReceived :$message")
+ message?.let {
+ val bytes = ByteArray(message.remaining())
+ message.get(bytes)
+ BinaryMessageHandler().unpack(bytes) { subtitleMsgData ->
+ binaryMessageReceived?.invoke(subtitleMsgData)
+ }
+ }
+ }
+
+ override fun onNetworkQuality(localQuality: NetworkQualityStats, remoteQualities: Array) {
+// log("onNetworkQuality: localQuality=$localQuality, remoteQualities=$remoteQualities")
+ }
+
+ private fun notifyLocalRTTUpdated(rtt: Int) {
+ log("notifyLocalRTTUpdated: rtt=$rtt")
+ }
+ }
+
+ private var userId = ""
+
+ fun createEngine(roomId: String, userId: String, token: String) {
+ this.userId = userId
+ if (mRTCVideo != null) {
+ log("createRTCVideo: already created")
+ return
+ } else {
+ joinRoom(roomId, userId, token)
+ }
+
+ val context = CommonApplicationProxy.application
+ val rtcVideo = RTCVideo.createRTCVideo(context, BuildConfig.RTC_APP_ID, mRTCVideoEventHandler, null, null)
+
+ val config = AudioPropertiesConfig(300)
+ with(rtcVideo) {
+ // 开启发言者音量监听
+ enableAudioPropertiesReport(config)
+ setAudioScenario(AudioScenarioType.AUDIO_SCENARIO_COMMUNICATION)
+ setAudioProfile(AudioProfileType.AUDIO_PROFILE_DEFAULT)
+
+// // 获取 AudioManager 实例
+// val audioManager =
+// CommonApplicationProxy.application.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+//
+// // 获取当前媒体音量
+// val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
+//
+// // 可选:获取最大媒体音量
+// val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+//
+// Timber.d("audioManager currentVolume:$currentVolume maxVolume:$maxVolume")
+//
+// setPlaybackVolume(1)
+ }
+
+ mRTCVideo = rtcVideo
+ if (mRTCVideo != null) {
+ joinRoom(roomId, userId, token)
+ }
+ }
+
+ fun destroyEngine() {
+ log("destroyEngine")
+ onAudioPropertiesReport = null
+ joinRoomCallback = null
+ binaryMessageReceived = null
+ leaveRoom()
+ if (mRTCVideo != null) {
+ RTCVideo.destroyRTCVideo()
+ mRTCVideo = null
+ }
+ }
+
+ fun joinRoom(roomId: String, userId: String, token: String) {
+ log("joinRoom: roomId=$roomId, userId=$userId, token=$token")
+
+ leaveRoom()
+ if (mRTCVideo == null) {
+ return
+ }
+ mRTCRoom = mRTCVideo?.createRTCRoom(roomId)
+ mRTCRoom?.setRTCRoomEventHandler(mRTCRoomEventHandler)
+ val userInfo = UserInfo(userId, null)
+ val roomConfig = RTCRoomConfig(
+ ChannelProfile.CHANNEL_PROFILE_CHAT,
+ true,
+ true,
+ false
+ )
+ mRTCRoom?.setUserVisibility(false)
+ val result = mRTCRoom?.joinRoom(token, userInfo, roomConfig)
+ startInteract()
+ log("joinRoom: result=$result")
+ }
+
+ fun leaveRoom() {
+ log("leaveRoom")
+ if (mRTCRoom != null) {
+ mRTCRoom?.leaveRoom()
+ mRTCRoom?.destroy()
+ }
+ }
+
+ fun startInteract() {
+ mRTCRoom?.setUserVisibility(true)
+ }
+
+ fun stopInteract() {
+ val userVisibility = mRTCRoom?.setUserVisibility(false)
+ }
+
+ /**
+ * 本地音频采集
+ * 开关推流
+ */
+ fun startAudioCapture(start: Boolean) {
+ if (mRTCVideo == null) {
+ return
+ }
+ if (start) {
+ val publishRes = mRTCRoom?.publishStream(MediaStreamType.RTC_MEDIA_STREAM_TYPE_AUDIO)
+ log("publishRes:$publishRes")
+ val startRes = mRTCVideo?.startAudioCapture()
+ log("startAudioCapture start:$startRes")
+ } else {
+ val stopRes = mRTCVideo?.stopAudioCapture()
+ log("startAudioCapture stop:$stopRes")
+ val unpublishRes = mRTCRoom?.unpublishStream(MediaStreamType.RTC_MEDIA_STREAM_TYPE_AUDIO)
+ log("unpublishRes:$unpublishRes")
+ }
+ }
+
+ fun sendEndMsg(accountId: String, durationTime: Long) {
+ val callType =
+ if (durationTime < TimeUtils.ONE_SECOND) CustomCallData.CALL_CANCEL else CustomCallData.CALL_END
+ val raw = CustomCallData(
+ CustomRawData.CALL,
+ CustomCallData.CALL_CANCEL, durationTime
+ )
+// WalletManager.refreshWallet()
+ val v2Message = V2NIMMessageCreator.createCustomMessage(raw.callTxt, Gson().toJson(raw))
+ NimManager.v2SendMessage(v2Message, accountId)
+ }
+
+ /**
+ * 心动等级变化
+ */
+ private val _levelFlow = MutableSharedFlow()
+ val levelFlow = _levelFlow.asSharedFlow()
+
+ fun sendIMLevelMessage(message: IMLevelMessage?) {
+ MainScope().launch { _levelFlow.emit(message) }
+ }
+
+ /**
+ * 语音通话中分数变化
+ */
+ private val _scoreFlow = MutableSharedFlow()
+ val scoreFlow = _scoreFlow.asSharedFlow()
+
+ fun sendIMScoreMessage(score: Double) {
+ MainScope().launch { _scoreFlow.emit(score) }
+ }
+
+ /**
+ * 更新心动关系
+ */
+ private val _relationLiveData = MutableSharedFlow()
+ val relationLiveData = _relationLiveData.asSharedFlow()
+
+ fun setRelation(relation: HeartbeatRelation?) {
+ MainScope().launch { _relationLiveData.emit(relation) }
+ }
+
+ /**
+ * 余额不足,关闭语音通话
+ */
+ private val _closeRCTFlow = MutableSharedFlow()
+ val closeRCTFlow = _closeRCTFlow.asSharedFlow()
+
+ fun balanceInsufficient() {
+ MainScope().launch { _closeRCTFlow.emit(true) }
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingLevelActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingLevelActivity.kt
new file mode 100644
index 0000000..bbeb505
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingLevelActivity.kt
@@ -0,0 +1,248 @@
+package com.remax.visualnovel.ui.chat.message.detail.flirting
+
+/**
+ * Created by HJW on 2025/8/16
+ */
+import android.annotation.SuppressLint
+import android.view.View
+import androidx.activity.viewModels
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import com.airbnb.lottie.LottieAnimationView
+import com.alibaba.android.arouter.facade.annotation.Autowired
+import com.alibaba.android.arouter.facade.annotation.Route
+import com.alibaba.android.arouter.launcher.ARouter
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.base.BaseBindingActivity
+import com.remax.visualnovel.app.delegate.isFull
+import com.remax.visualnovel.app.delegate.titleTextAlpha
+import com.remax.visualnovel.app.widget.tips.TipsSwitchWindow
+import com.remax.visualnovel.entity.request.AIIsShowDTO
+import com.remax.visualnovel.entity.response.HeartbeatLevel
+import com.remax.visualnovel.extension.addScrollerAlpha
+import com.remax.visualnovel.extension.getTemperatureTxt
+import com.remax.visualnovel.extension.glide.loadAndRoundCorner
+import com.remax.visualnovel.extension.launchAndLoadingCollect
+import com.remax.visualnovel.extension.setMargin
+import com.remax.visualnovel.extension.setOnClick
+import com.remax.visualnovel.extension.setSize
+import com.remax.visualnovel.extension.setSpanTypeFace
+import com.remax.visualnovel.extension.showMoreTxtDialog
+import com.remax.visualnovel.extension.translationYObjectAnimator
+import com.remax.visualnovel.utils.Routers
+import com.remax.visualnovel.utils.spannablex.spannable
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.ui.UserAvatarView
+import com.remax.visualnovel.widget.uitoken.changeTextFont
+import com.remax.visualnovel.widget.uitoken.handleUIToken
+import com.drake.brv.annotaion.DividerOrientation
+import com.drake.brv.utils.divider
+import com.drake.brv.utils.grid
+import com.drake.brv.utils.models
+import com.drake.brv.utils.setup
+import com.remax.visualnovel.databinding.ActivityFlirtingLevelBinding
+import com.remax.visualnovel.databinding.ItemFlirtingLevelBinding
+import dagger.hilt.android.AndroidEntryPoint
+import timber.log.Timber
+
+@AndroidEntryPoint
+@Route(path = Routers.CHAT_FLIRTING_LEVEL)
+class FlirtingLevelActivity : BaseBindingActivity() {
+
+ private val flirtingLevelViewModel by viewModels()
+
+ @JvmField
+ @Autowired
+ var aiId = ""
+
+ @SuppressLint("SetTextI18n")
+ override fun initView() {
+ ARouter.getInstance().inject(this)
+ setToolbar(R.string.app_name) {
+ isFull = true
+ titleTextAlpha = 1f
+ setRightIconBtn1(R.string.icon_more) {
+ val switchWindow = TipsSwitchWindow()
+ switchWindow.build(
+ this@FlirtingLevelActivity,
+ R.string.hide_relations,
+ flirtingLevelViewModel.levelOutput?.aiUserHeartbeatRelation?.isShow != true
+ ) { switchView, isChecked ->
+ launchAndLoadingCollect({
+ flirtingLevelViewModel.relationSwitch(AIIsShowDTO(aiId, if (isChecked) 0 else 1))
+ }) {
+ onSuccess = {
+ with(binding) {
+ listOf(tvMeet, dividerMeet1, dividerMeet2).forEach { view ->
+ view.isGone =
+ flirtingLevelViewModel.levelOutput?.aiUserHeartbeatRelation?.isShow != true
+ }
+ }
+ }
+
+ onFailed = { _, _ ->
+ switchView.isChecked = !isChecked
+ }
+ }
+ }
+ switchWindow.showAsDropDown(this)
+ }
+
+ setRightIconBtn2(R.string.icon_faq) {
+ showMoreTxtDialog(
+ texts = listOf(
+ R.string.flirting_tips_txt_1,
+ R.string.flirting_tips_txt_2,
+ R.string.flirting_tips_txt_3
+ ).map { getString(it) })
+ }
+ }
+ with(binding) {
+ scrollView.addScrollerAlpha(aiAvatarView, includeBgAlpha = true, textAlpha = false)
+
+ tvMeet.text = "· ${getString(R.string.meet)} ·"
+
+ setOnClick(retrieveGroup) {
+ flirtingLevelViewModel.levelOutput?.aiUserHeartbeatRelation?.let {
+ val retrieveDialog = FlirtingRetrieveDialog(this@FlirtingLevelActivity)
+ retrieveDialog.build(it) {
+ launchAndLoadingCollect({
+ flirtingLevelViewModel.buyHeartbeatVal(aiId)
+ }) {
+ onSuccess = {
+ initData()
+ retrieveDialog.dismiss()
+ }
+ }
+ }
+ retrieveDialog.binding.run {
+ showHeartAnim(levelBg, levelBgTop, lottieView, 84f)
+ }
+ retrieveDialog.show()
+ }
+ }
+
+ rv.grid(2)
+ .divider {
+ setDivider(16, true)
+ orientation = DividerOrientation.VERTICAL
+ }.setup {
+ addType(R.layout.item_flirting_level)
+ onBind {
+ val item = getModel()
+ with(getBinding()) {
+ tvName.text = item.name
+ tvName.isEnabled = item.isUnlock
+ lockView.isVisible = !item.isUnlock
+ imageView.loadAndRoundCorner(item.imgUrl, 16)
+ tvTemperature.text = item.startVal.getTemperatureTxt()
+ tvTemperature.isEnabled = item.isUnlock
+ imageStroke.isVisible = item.isUnlock
+ }
+ }
+ }
+ }
+ }
+
+ @SuppressLint("SetTextI18n", "DefaultLocale")
+ override fun initData() {
+ launchAndLoadingCollect({
+ flirtingLevelViewModel.getHeartbeatLevel(aiId)
+ }) {
+ onSuccess = {
+ val relation = it?.aiUserHeartbeatRelation
+ with(binding) {
+ listOf(tvMeet, dividerMeet1, dividerMeet2).forEach { view ->
+ view.isGone = relation?.isShow != true
+ }
+
+ showHeartAnim(levelBg, levelBgTop, lottieView, avatarView = aiAvatarView)
+
+ aiAvatarView.loadAvatar(relation?.aiHeadImg)
+ myAvatarView.loadAvatar(relation?.userHeadImg)
+ val currHeartbeat = relation?.currHeartbeatEnum
+ if (currHeartbeat == null) {
+ tvMeet.changeTextFont {
+ textUITextToken = getString(R.string.txt_title_s)
+ }
+ tvMeet.setText(R.string.no_intention_yet)
+ } else {
+ tvMeet.changeTextFont {
+ textUITextToken = getString(R.string.txt_display_s)
+ }
+ tvMeet.setText(currHeartbeat.tagName)
+ }
+ val heartbeatScore = String.format("%.2f", (relation?.heartbeatScore ?: 0.0f) * 100)
+
+ tvMeetDesc.text =
+ getString(R.string.flirting_desc, relation?.dayCount ?: 0, heartbeatScore)
+ tvLevel.text = spannable {
+ if (currHeartbeat != null) {
+ currHeartbeat.levelContent.text()
+ " 丨 ".color(handleUIToken(R.string.color_outline_normal)?.color ?: 0)
+ }
+ (relation?.heartbeatVal ?: 0.0).toString().text()
+ getString(R.string.temperature).span {
+ setSpanTypeFace(this@FlirtingLevelActivity, R.string.txt_numMonotype_s)
+ }
+ }
+
+ /**
+ * 已经扣减的心动分
+ */
+ retrieveGroup.isVisible = (relation?.subtractHeartbeatVal ?: 0.0) != 0.0
+ retrieveTitle.text =
+ getString(R.string.flirting_deducted_desc, (relation?.subtractHeartbeatVal ?: 0.0).toString())
+
+ rv.models = it?.heartbeatLeveLDictList
+ }
+ }
+ }
+ }
+
+ private fun showHeartAnim(
+ levelBg: View,
+ levelBgTop: View,
+ heartLottie: LottieAnimationView,
+ bgBottomMargin: Float = 0f,
+ avatarView: UserAvatarView? = null,
+ ) {
+ levelBg.post {
+ val currProgress =
+ flirtingLevelViewModel.levelOutput?.aiUserHeartbeatRelation?.currHeartbeatEnum?.level ?: 0
+ val progress = currProgress / 10f
+
+ // 弹窗中的切图需要往上移动
+ levelBg.setMargin(bottomMargin = -((bgBottomMargin / 400f) * levelBgTop.measuredHeight).toInt())
+
+ // 根据切图比例调整宽高
+ val heartSize = ((98f / 214f) * levelBgTop.measuredHeight).toInt()
+ heartLottie.setSize(heartSize, heartSize)
+
+ // 头像框距离顶部的边距
+ val marginTop = ((120f / 214f) * levelBgTop.measuredHeight) + (heartSize - 64.dp) / 2f
+ avatarView?.setMargin(topMargin = marginTop.toInt())
+ avatarView?.isVisible = true
+ binding.myAvatarView.isVisible = true
+
+ // 动画持续时间
+ val totalTime = 1000L
+ // 总高度进度算
+ val totalHeight = progress * heartSize
+ Timber.i("showHeartAnim heartSize:$heartSize totalHeight:$totalHeight")
+ heartLottie.translationYObjectAnimator(-totalHeight, totalTime)
+ }
+ }
+
+ companion object {
+ fun start(aiId: String?) {
+ if (aiId != null) {
+ ARouter.getInstance()
+ .build(Routers.CHAT_FLIRTING_LEVEL)
+ .withString("aiId", aiId)
+ .navigation()
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingLevelViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingLevelViewModel.kt
new file mode 100644
index 0000000..24a0bde
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingLevelViewModel.kt
@@ -0,0 +1,32 @@
+package com.remax.visualnovel.ui.chat.message.detail.flirting
+
+/**
+ * Created by HJW on 2025/8/16
+ */
+import com.remax.visualnovel.app.viewmodel.base.BaseViewModel
+import com.remax.visualnovel.entity.request.AIIsShowDTO
+import com.remax.visualnovel.entity.request.HeartbeatBuy
+import com.remax.visualnovel.entity.response.HeartbeatLevelOutput
+import com.remax.visualnovel.repository.api.ChatRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class FlirtingLevelViewModel @Inject constructor(private val chatRepository: ChatRepository) : BaseViewModel() {
+
+ var levelOutput: HeartbeatLevelOutput? = null
+ private set
+
+ suspend fun getHeartbeatLevel(aiId: String) = chatRepository.getHeartbeatLevel(aiId).transformResult({
+ levelOutput = it
+ })
+
+ suspend fun relationSwitch(request: AIIsShowDTO) = chatRepository.relationSwitch(request).transformResult({
+ levelOutput?.aiUserHeartbeatRelation?.isShow = request.isShow == 1
+ })
+
+ suspend fun buyHeartbeatVal(aiId: String) = chatRepository.buyHeartbeatVal(HeartbeatBuy(
+ aiId,
+ levelOutput?.aiUserHeartbeatRelation?.subtractHeartbeatVal
+ ))
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingRetrieveDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingRetrieveDialog.kt
new file mode 100644
index 0000000..b14aa75
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/detail/flirting/FlirtingRetrieveDialog.kt
@@ -0,0 +1,42 @@
+package com.remax.visualnovel.ui.chat.message.detail.flirting
+
+import android.content.Context
+import com.remax.visualnovel.R
+import com.remax.visualnovel.databinding.DialogFlirtingRetrieveBinding
+import com.remax.visualnovel.entity.request.HeartbeatRelation
+import com.remax.visualnovel.extension.formatPrice
+import com.remax.visualnovel.extension.getTemperatureTxt
+import com.remax.visualnovel.extension.setOnClick
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.remax.visualnovel.widget.dialoglib.LBindingDialog
+
+/**
+ * Created by HJW on 2025/8/17
+ */
+
+class FlirtingRetrieveDialog(context: Context) :
+ LBindingDialog(context, DialogFlirtingRetrieveBinding::inflate) {
+
+ fun build(relation: HeartbeatRelation, buyCallback: () -> Unit): FlirtingRetrieveDialog {
+ with()
+ setBottom()
+ setBgColorToken(R.string.color_transparent)
+ setCancelBtn(R.id.cancel)
+ with(binding) {
+ quantity.text = relation.subtractHeartbeatVal?.getTemperatureTxt()
+ priceView.setPrice(formatPrice(relation.price))
+ val total = maxOf(1.0, (relation.subtractHeartbeatVal ?: 0.0) * (relation.price ?: 0L))
+ relationPrice.setPrice(formatPrice(total.toLong()))
+
+ setOnClick(purchaseBtn) {
+ if (WalletManager.balance < total) {
+ WalletManager.showChargeDialog()
+ } else {
+ buyCallback.invoke()
+ }
+ }
+ }
+ return this
+ }
+
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/ChatSettingEvents.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/ChatSettingEvents.kt
new file mode 100644
index 0000000..a266ea8
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/ChatSettingEvents.kt
@@ -0,0 +1,24 @@
+package com.remax.visualnovel.ui.chat.message.events
+
+import com.remax.visualnovel.entity.request.AIFeedback
+import com.remax.visualnovel.entity.request.AIIDRequest
+import com.pengxr.modular.eventbus.facade.annotation.EventGroup
+
+
+/**
+ * Created by HJW on 2023/5/18
+ * 用户修改了AI相关的设置
+ */
+@EventGroup(moduleName = "chatSetting", autoClear = true)
+interface ChatSettingEvents {
+
+ /**
+ * 修改设置
+ */
+ fun settingChanged(): AIIDRequest
+
+ /**
+ * 修改点赞/点踩
+ */
+ fun aiResponseFeedback(): AIFeedback
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetAutoPlayEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetAutoPlayEvent.kt
new file mode 100644
index 0000000..e434f5d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetAutoPlayEvent.kt
@@ -0,0 +1,14 @@
+package com.remax.visualnovel.ui.chat.message.events.model
+
+import com.remax.visualnovel.entity.request.AIIDRequest
+
+/**
+ * Created by HJW on 2025/8/24
+ */
+data class ChatSetAutoPlayEvent(
+ override val aiId: String,
+ /**
+ * 自动播放语音开关 1:开 0:关
+ */
+ val isAutoPlayVoice: Int? = null,
+) : AIIDRequest(aiId)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetBackgroundEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetBackgroundEvent.kt
new file mode 100644
index 0000000..f4612f4
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetBackgroundEvent.kt
@@ -0,0 +1,14 @@
+package com.remax.visualnovel.ui.chat.message.events.model
+
+import com.remax.visualnovel.entity.request.AIIDRequest
+
+/**
+ * Created by HJW on 2025/8/24
+ */
+data class ChatSetBackgroundEvent(
+ override val aiId: String,
+ /**
+ * 聊天背景图片
+ */
+ val backgroundImg: String? = null,
+): AIIDRequest(aiId)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetBubbleEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetBubbleEvent.kt
new file mode 100644
index 0000000..add5cb3
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/message/events/model/ChatSetBubbleEvent.kt
@@ -0,0 +1,16 @@
+package com.remax.visualnovel.ui.chat.message.events.model
+
+import com.remax.visualnovel.entity.request.AIIDRequest
+import com.remax.visualnovel.entity.response.ChatBubble
+
+/**
+ * Created by HJW on 2025/8/24
+ */
+data class ChatSetBubbleEvent(
+ override val aiId: String,
+
+ /**
+ * 聊天气泡
+ */
+ val bubble: ChatBubble? = null,
+): AIIDRequest(aiId)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/model/ChatModelDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/model/ChatModelDialog.kt
new file mode 100644
index 0000000..053b046
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/model/ChatModelDialog.kt
@@ -0,0 +1,115 @@
+package com.remax.visualnovel.ui.chat.setting.model
+
+import android.app.Activity
+import androidx.core.view.isVisible
+import com.remax.visualnovel.R
+import com.remax.visualnovel.databinding.DialogChatModelBinding
+import com.remax.visualnovel.databinding.ItemChatModelBinding
+import com.remax.visualnovel.entity.response.ChatModel
+import com.remax.visualnovel.extension.formatPrice
+import com.remax.visualnovel.extension.setOnClick
+import com.remax.visualnovel.extension.showMoreTxtDialog
+import com.remax.visualnovel.ui.wallet.WalletActivity
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.remax.visualnovel.widget.dialoglib.LBindingDialog
+import com.drake.brv.utils.divider
+import com.drake.brv.utils.linear
+import com.drake.brv.utils.setup
+
+/**
+ * Created by HJW on 2025/8/18
+ */
+class ChatModelDialog(private val activity: Activity) :
+ LBindingDialog(activity, DialogChatModelBinding::inflate) {
+
+ private var selectCode: String? = null
+
+ fun build(
+ models: List,
+ selectCode: String?,
+ recharge: (() -> Unit)? = null,
+ click: (ChatModel) -> Unit
+ ) {
+ this.selectCode = selectCode
+ with()
+ setBottom()
+ setCancelBtn(R.id.cancel)
+ with(binding) {
+ dialogTitle.setText(if (recharge == null) R.string.dialog_model else R.string.recharge_dialog_title)
+ modelHint.isVisible = recharge == null
+ listOf(btnRecharge, dialogText, priceView).forEach {
+ it.isVisible = !modelHint.isVisible
+ }
+
+ setOnClick(btnRecharge) {
+ WalletActivity.start()
+ dismiss()
+ }
+ priceView.setPrice(formatPrice(WalletManager.balance))
+
+ rvModel.linear()
+ .divider {
+ setDivider(16, true)
+ }.setup {
+ addType(R.layout.item_chat_model)
+ onClick(R.id.group) {
+ val item = getModel()
+ click.invoke(item)
+ }
+ onBind {
+ val item = getModel()
+ with(getBinding()) {
+ modelSelect.isVisible = item.code == selectCode
+ modelTitle.text = item.name
+ modelDesc.text = item.description
+ priceView1.setPrice("${formatPrice(item.textPrice)}/${context.getString(R.string.text_message)}")
+ priceView2.setPrice("${formatPrice(item.voicePrice)}/${context.getString(R.string.send_or_play_voice)}")
+ priceView3.setPrice("${formatPrice(item.voiceChatPrice)}/${context.getString(R.string.voice_call_message)}")
+
+ setOnClick(priceTips) {
+ activity.showMoreTxtDialog(
+ texts = listOf(
+ item.questionMark ?: ""
+ )
+ )
+ }
+ }
+ }
+ }.models = models
+ }
+ }
+
+
+// inner class ChatModelAdapter(private val activity: Activity, data: List) :
+// BannerAdapter(data) {
+//
+// inner class ViewHolder(view: View, itemBinding: ItemChatModelBinding) : RecyclerView.ViewHolder(view) {
+// val binding = itemBinding
+// }
+//
+// override fun onCreateHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+// val binding = ItemChatModelBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+// return ViewHolder(binding.root, binding)
+// }
+//
+// override fun onBindView(holder: ViewHolder?, data: ChatModel?, position: Int, size: Int) {
+// data?.let { item ->
+// holder?.binding?.run {
+// modelSelect.isVisible = item.code == selectCode
+// modelTitle.text = item.name
+// modelDesc.text = item.description
+// priceView1.setPrice("${item.textPrice}/${context.getString(R.string.text_message)}")
+// priceView2.setPrice("${item.voiceChatPrice}/${context.getString(R.string.voice_call_message)}")
+//
+// setOnClick(priceTips) {
+// activity.showMoreTxtDialog(
+// texts = listOf(
+// item.questionMark ?: ""
+// )
+// )
+// }
+// }
+// }
+// }
+// }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ui/HoldToTalkDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ui/HoldToTalkDialog.kt
new file mode 100644
index 0000000..788d5c4
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/ui/HoldToTalkDialog.kt
@@ -0,0 +1,25 @@
+package com.remax.visualnovel.ui.chat.ui
+
+import android.content.Context
+import com.remax.visualnovel.R
+import com.remax.visualnovel.databinding.DialogHoldToTalkBinding
+import com.remax.visualnovel.widget.dialoglib.LBindingDialog
+
+/**
+ * Created by HJW on 2025/8/16
+ */
+class HoldToTalkDialog(context: Context) :
+ LBindingDialog(context, DialogHoldToTalkBinding::inflate) {
+
+ fun build(): HoldToTalkDialog {
+ with()
+ setBottom()
+ setBgColorToken(R.string.color_transparent)
+ setMaskValue(0f)
+
+ show()
+
+ return this
+ }
+
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/actor/ActorsAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/actor/ActorsAdapter.kt
index 53583d8..a947006 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/actor/ActorsAdapter.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/actor/ActorsAdapter.kt
@@ -5,7 +5,7 @@ import com.chad.library.adapter.base.module.LoadMoreModule
import com.remax.visualnovel.R
import com.remax.visualnovel.app.BaseBindingQuickAdapter
import com.remax.visualnovel.databinding.FragmentMainActorItemBinding
-import com.remax.visualnovel.ui.Chat.ChatActivity
+import com.remax.visualnovel.ui.chat.ChatActivity
class ActorsAdapter : BaseBindingQuickAdapter(FragmentMainActorItemBinding::inflate), LoadMoreModule {
init {
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/WalletActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/WalletActivity.kt
new file mode 100644
index 0000000..f81df84
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/WalletActivity.kt
@@ -0,0 +1,107 @@
+package com.remax.visualnovel.ui.wallet
+
+/**
+ * Created by HJW on 2025/8/26
+ */
+
+import android.annotation.SuppressLint
+import androidx.activity.viewModels
+import androidx.core.view.isVisible
+import com.alibaba.android.arouter.facade.annotation.Route
+import com.alibaba.android.arouter.launcher.ARouter
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.base.BaseBindingActivity
+import com.remax.visualnovel.databinding.ActivityWalletBinding
+import com.remax.visualnovel.extension.addScrollerAlpha
+import com.remax.visualnovel.extension.launchAndLoadingCollect
+import com.remax.visualnovel.extension.setMagicIndicator
+import com.remax.visualnovel.extension.setOnClick
+import com.remax.visualnovel.ui.wallet.income.IncomeFragment
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.remax.visualnovel.ui.wallet.recharge.RechargeFragment
+import com.remax.visualnovel.utils.Routers
+import com.remax.visualnovel.utils.spannablex.activateClick
+import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig
+import com.remax.visualnovel.utils.spannablex.spannable
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.indicator.SecondaryNavigatorAdapter
+import com.remax.visualnovel.widget.uitoken.expend.dsl.expandDp
+import com.remax.visualnovel.widget.uitoken.handleUIToken
+import dagger.hilt.android.AndroidEntryPoint
+import kotlin.getValue
+
+@AndroidEntryPoint
+@Route(path = Routers.WALLET)
+class WalletActivity : BaseBindingActivity() {
+
+ private val walletViewModel by viewModels()
+
+ override fun initView() {
+ setToolbar(R.string.wallet)
+ with(binding) {
+ appBarLayout.addScrollerAlpha(tvTitle, isFromToolbar = true)
+
+ setMagicIndicator(
+ listOf(RechargeFragment.newInstance(), IncomeFragment.newInstance()),
+ SecondaryNavigatorAdapter(
+ listOf(
+ R.string.recharge,
+ R.string.income,
+ ).map { res -> getString(res) }, viewPager2 = viewPager
+ ),
+ indicator, leftPadding = 24.dp
+ ) {
+ bottomLayout.isVisible = it == 0
+ }
+
+ agreement.activateClick(false).text = spannable {
+ getString(R.string.recharge_agreement_hint).text()
+
+ val color = handleUIToken(R.string.color_primary_variant_normal)?.color ?: 0
+ " ${getString(R.string.recharge_agreement)}".span {
+ clickable(color, config = SimpleClickableConfig(false)) { _, _ ->
+ Routers.navigationToRA()
+ }
+ }
+ }
+
+ radioRecharge.expandDp(30, 30)
+ setOnClick(radioRecharge, btnRecharge) {
+ when (this) {
+ radioRecharge -> {
+ radioRecharge.viewChecked(!radioRecharge.isChecked)
+ btnRecharge.isEnabled = radioRecharge.isChecked
+ }
+
+ btnRecharge -> {
+ walletViewModel.products?.find { it.selected }?.let {
+ WalletManager.onCreateOrder(it)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun initData() {
+ launchAndLoadingCollect({
+ walletViewModel.getMyWallet()
+ })
+ }
+
+ @SuppressLint("SetTextI18n")
+ fun refreshRechargeTxt() {
+ walletViewModel.products?.find { it.selected }?.let { item ->
+ binding.btnRecharge.text = "${getString(R.string.recharge)} ${item.local}"
+ }
+ }
+
+ companion object {
+ fun start() {
+ ARouter.getInstance()
+ .build(Routers.WALLET)
+ .navigation()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/WalletViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/WalletViewModel.kt
new file mode 100644
index 0000000..cf427f3
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/WalletViewModel.kt
@@ -0,0 +1,44 @@
+package com.remax.visualnovel.ui.wallet
+
+/**
+ * Created by HJW on 2025/8/26
+ */
+
+import com.remax.visualnovel.app.viewmodel.base.BaseViewModel
+import com.remax.visualnovel.entity.request.ChargeProduct
+import com.remax.visualnovel.entity.request.PageQuery
+import com.remax.visualnovel.entity.request.SearchPage
+import com.remax.visualnovel.entity.response.Transaction
+import com.remax.visualnovel.entity.response.base.Response
+import com.remax.visualnovel.repository.api.PayRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class WalletViewModel @Inject constructor(private val payRepository: PayRepository) : BaseViewModel() {
+
+ private var currPn = 1
+
+ suspend fun getBillList(isRefresh: Boolean, type: String?): Response {
+ if (isRefresh) currPn = 1
+ return payRepository.getTransactionList(
+ SearchPage(
+ PageQuery.Page(currPn), type = type
+ )
+ ).transformResult({
+ currPn++
+ })
+ }
+
+
+ suspend fun getMyWallet() = payRepository.getMyWallet()
+
+ var products: List? = null
+ private set
+
+ suspend fun getProducts() = payRepository.getProducts().transformResult({
+ products = it?.productList
+ products?.firstOrNull()?.selected = true
+ })
+
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/dialog/RechargeDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/dialog/RechargeDialog.kt
new file mode 100644
index 0000000..1c8b6f3
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/dialog/RechargeDialog.kt
@@ -0,0 +1,83 @@
+package com.remax.visualnovel.ui.wallet.dialog
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import androidx.recyclerview.widget.RecyclerView
+import com.remax.visualnovel.R
+import com.remax.visualnovel.databinding.DialogRechargeBinding
+import com.remax.visualnovel.databinding.ItemRechargeBinding
+import com.remax.visualnovel.entity.request.ChargeProduct
+import com.remax.visualnovel.extension.formatPrice
+import com.remax.visualnovel.extension.setOnClick
+import com.remax.visualnovel.ui.wallet.manager.WalletManager
+import com.remax.visualnovel.widget.dialoglib.LBindingDialog
+import com.remax.visualnovel.widget.uitoken.changeBackground
+import com.drake.brv.annotaion.DividerOrientation
+import com.drake.brv.utils.divider
+import com.drake.brv.utils.grid
+import com.drake.brv.utils.setup
+
+/**
+ * Created by HJW on 2025/9/15
+ */
+class RechargeDialog(val activity: Activity) :
+ LBindingDialog(activity, DialogRechargeBinding::inflate) {
+
+ @SuppressLint("SetTextI18n")
+ fun build(productList: List) {
+ with()
+ setBottom()
+ setCancelBtn(R.id.cancelBtn)
+ with(binding) {
+ balance.setPrice(formatPrice(WalletManager.balance))
+ rvRecharge.setRechargeProduct(productList) { item ->
+ btnRecharge.text = "${activity.getString(R.string.recharge)} ${item.local}"
+ }
+ setOnClick(btnRecharge) {
+ productList.find { it.selected }?.let { product ->
+ WalletManager.onCreateOrder(product)
+ dismiss()
+ }
+ }
+ }
+ show()
+ }
+}
+
+@SuppressLint("NotifyDataSetChanged")
+fun RecyclerView.setRechargeProduct(productList: List?, selectCallback: (ChargeProduct) -> Unit) {
+ grid(2)
+ .divider {
+ setDivider(16, true)
+ orientation = DividerOrientation.VERTICAL
+ }.setup {
+ addType(R.layout.item_recharge)
+ onClick(R.id.group) {
+ val item = getModel()
+ models?.forEach { product ->
+ (product as? ChargeProduct)?.apply {
+ selected = item == product
+ if (selected) {
+ selectCallback.invoke(item)
+ }
+ }
+ }
+ bindingAdapter?.notifyDataSetChanged()
+ }
+ onBind {
+ val item = getModel()
+ with(getBinding()) {
+ group.changeBackground {
+ strokeUIColorToken = context.getString(
+ if (item.selected)
+ R.string.color_primary_normal
+ else
+ R.string.color_surface_base_normal
+ )
+ }
+ priceView.setPrice(item.chargeAmount.toString())
+ amountView.text = item.local
+ }
+ }
+ }.models = productList
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/income/IncomeFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/income/IncomeFragment.kt
new file mode 100644
index 0000000..75cc208
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/income/IncomeFragment.kt
@@ -0,0 +1,113 @@
+package com.remax.visualnovel.ui.wallet.income
+
+/**
+ * Created by HJW on 2025/9/12
+ */
+import android.annotation.SuppressLint
+import android.os.Bundle
+import androidx.fragment.app.activityViewModels
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.alibaba.android.arouter.facade.annotation.Route
+import com.alibaba.android.arouter.launcher.ARouter
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.base.BaseBindingFragment
+import com.remax.visualnovel.databinding.ItemIncomeHeaderBinding
+import com.remax.visualnovel.databinding.LayoutSimpleRecyclerviewRefreshBinding
+import com.remax.visualnovel.entity.response.AccountBuffBill
+import com.remax.visualnovel.extension.autoRefreshList
+import com.remax.visualnovel.extension.formatPrice
+import com.remax.visualnovel.extension.launchAndCollect
+import com.remax.visualnovel.extension.setOnClick
+import com.remax.visualnovel.extension.showSingleBtnDialog
+import com.remax.visualnovel.ui.wallet.WalletViewModel
+import com.remax.visualnovel.ui.wallet.transaction.TransactionAdapter
+import com.remax.visualnovel.ui.wallet.transaction.TransactionDetailActivity
+import com.remax.visualnovel.utils.Routers
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.itemdecoration.SpaceItemDecoration
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+@Route(path = Routers.INCOME)
+class IncomeFragment : BaseBindingFragment() {
+
+ private val walletViewModel by activityViewModels()
+
+ private val transactionAdapter by lazy {
+ TransactionAdapter(AccountBuffBill.INCOME)
+ }
+
+ private val incomeHeaderBinding by lazy {
+ ItemIncomeHeaderBinding.inflate(layoutInflater)
+ }
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreated(bundle: Bundle?) {
+ with(binding) {
+ setOnClick(incomeHeaderBinding.transactionEnter, incomeHeaderBinding.pendingTips) {
+ when (this) {
+ incomeHeaderBinding.transactionEnter -> {
+ TransactionDetailActivity.start()
+ }
+
+ incomeHeaderBinding.pendingTips -> {
+ activity?.showSingleBtnDialog(text = getString(R.string.transaction_tips))
+ }
+ }
+ }
+
+ with(refreshLayout) {
+ refreshLayout.autoRefreshList()
+ onRefresh {
+ getData(true)
+ }
+ }
+ with(recyclerView) {
+ adapter = transactionAdapter
+ layoutManager = LinearLayoutManager(recyclerView.context)
+ addItemDecoration(SpaceItemDecoration(16.dp, 24.dp,0,24.dp))
+ with(transactionAdapter) {
+ addHeaderView(incomeHeaderBinding.root)
+ incomeHeaderBinding.transactionEnter.text = "${getString(R.string.transaction_detail)}>"
+ headerWithEmptyEnable = true
+ loadMoreModule.setOnLoadMoreListener {
+ getData(false)
+ }
+ }
+ }
+ }
+ }
+
+ override fun subscribeUi() {
+ EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().observe(this) {
+ incomeHeaderBinding.income.setPrice(formatPrice(it?.income))
+ incomeHeaderBinding.pending.setPrice(formatPrice(it?.awaitingIncome))
+ }
+ }
+
+ private fun getData(isRefresh: Boolean) {
+ launchAndCollect({
+ walletViewModel.getBillList(isRefresh, AccountBuffBill.INCOME)
+ }) {
+ onSuccess = {
+ val data = it?.pageList?.datas ?: emptyList()
+ transactionAdapter.setTransactionData(isRefresh, data, 32)
+ }
+
+ onComplete = {
+ if (isRefresh) {
+ binding.refreshLayout.finishRefresh()
+ }
+ }
+ }
+ }
+
+ companion object {
+ fun newInstance(): IncomeFragment {
+ return ARouter.getInstance().build(Routers.INCOME)
+ .navigation() as IncomeFragment
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/manager/WalletManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/manager/WalletManager.kt
new file mode 100644
index 0000000..8b4fa8e
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/manager/WalletManager.kt
@@ -0,0 +1,116 @@
+package com.remax.visualnovel.ui.wallet.manager
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.Observer
+import com.remax.visualnovel.R
+import com.remax.visualnovel.api.factory.ServiceFactory
+import com.remax.visualnovel.app.base.BaseBindingActivity
+import com.remax.visualnovel.entity.request.ChargeProduct
+import com.remax.visualnovel.entity.response.Wallet
+import com.remax.visualnovel.entity.response.base.parseData
+import com.remax.visualnovel.extension.launchFlow
+import com.remax.visualnovel.extension.toast
+import com.remax.visualnovel.manager.pay.GooglePayManager
+import com.remax.visualnovel.repository.api.PayRepository
+import com.remax.visualnovel.ui.wallet.dialog.RechargeDialog
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents
+import com.remax.visualnovel.configs.NovelApplication
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+/**
+ * Created by HJW on 2025/9/15
+ */
+object WalletManager : DefaultLifecycleObserver {
+
+ var balance: Long = 0
+
+ private val walletObserver = Observer { wallet ->
+ balance = wallet?.balance ?: 0L
+ }
+
+ override fun onCreate(owner: LifecycleOwner) {
+ EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().observeForever(walletObserver)
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ refreshRechargeProducts()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().removeObserver(walletObserver)
+ }
+
+ private val payRepository by lazy {
+ PayRepository(ServiceFactory.createService())
+ }
+
+ fun refreshWallet() {
+ MainScope().launch {
+ launchFlow(payRepository::getMyWallet).collect()
+ }
+ }
+
+ private val productList by lazy {
+ arrayListOf()
+ }
+
+ /**
+ * 创建google pay充值订单
+ * @param product ChargeProduct 后端返回的实体对象
+ */
+ fun onCreateOrder(product: ChargeProduct) {
+ MainScope().launch {
+ launchFlow({
+ payRepository.createOrder(product)
+ }, {
+ (NovelApplication.getCurrentActivity() as? BaseBindingActivity<*>)?.showLoading()
+ }) {
+ (NovelApplication.getCurrentActivity() as? BaseBindingActivity<*>)?.hideLoading()
+ }.collect {
+ it.parseData({
+ onSuccess = { res ->
+ res?.run {
+ (NovelApplication.getCurrentActivity() as? BaseBindingActivity<*>)?.let { act ->
+ GooglePayManager.pay(act, product.productId, tradeNo = res.tradeNo) {
+ act.toast(act.getString(R.string.google_pay_fail_toast))
+ }
+ }
+ }
+ }
+
+ onFailed = { errorCode, _ ->
+
+ }
+ })
+ }
+ }
+ }
+
+ fun refreshRechargeProducts() {
+ GooglePayManager.checkProductDetails()
+ MainScope().launch {
+ if (productList.isEmpty()) {
+ payRepository.getProducts().transformResult({
+ productList.clear()
+ productList.addAll(it?.productList ?: emptyList())
+ })
+ }
+ }
+ }
+
+ fun showChargeDialog() {
+ if (productList.isNotEmpty()) {
+ NovelApplication.getCurrentActivity()?.let { act ->
+ productList.forEachIndexed { index, product ->
+ product.selected = index == 0
+ }
+ RechargeDialog(act).build(productList)
+ }
+ } else {
+ refreshRechargeProducts()
+ }
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/recharge/RechargeFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/recharge/RechargeFragment.kt
new file mode 100644
index 0000000..8efaf57
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/recharge/RechargeFragment.kt
@@ -0,0 +1,83 @@
+package com.remax.visualnovel.ui.wallet.recharge
+
+/**
+ * Created by HJW on 2025/9/12
+ */
+import android.annotation.SuppressLint
+import android.os.Bundle
+import androidx.fragment.app.activityViewModels
+import com.alibaba.android.arouter.facade.annotation.Route
+import com.alibaba.android.arouter.launcher.ARouter
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.base.BaseBindingFragment
+import com.remax.visualnovel.databinding.FragmentRechargeBinding
+import com.remax.visualnovel.extension.formatPrice
+import com.remax.visualnovel.extension.launchAndCollect
+import com.remax.visualnovel.extension.setOnClick
+import com.remax.visualnovel.manager.pay.GooglePayManager
+import com.remax.visualnovel.ui.wallet.WalletActivity
+import com.remax.visualnovel.ui.wallet.WalletViewModel
+import com.remax.visualnovel.ui.wallet.dialog.setRechargeProduct
+import com.remax.visualnovel.ui.wallet.transaction.TransactionDetailActivity
+import com.remax.visualnovel.utils.Routers
+import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+@Route(path = Routers.RECHARGE)
+class RechargeFragment : BaseBindingFragment() {
+
+ private val walletViewModel by activityViewModels()
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreated(bundle: Bundle?) {
+ with(binding) {
+ transactionEnter.text = "${getString(R.string.transaction_detail)}>"
+ setOnClick(transactionEnter) {
+ when (this) {
+ transactionEnter -> {
+ TransactionDetailActivity.start()
+ }
+ }
+ }
+ }
+ getProducts()
+ }
+
+ @SuppressLint("SetTextI18n")
+ private fun getProducts() {
+ launchAndCollect({
+ walletViewModel.getProducts()
+ }) {
+ onSuccess = {
+ it?.productList?.forEachIndexed { index, chargeProduct ->
+ GooglePayManager.productDetails.forEach { productDetails ->
+ if (chargeProduct.productId == productDetails?.productId) {
+ chargeProduct.local = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
+ chargeProduct.localCurrencyCode =
+ productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode ?: "USD"
+ }
+ }
+ }
+ (activity as? WalletActivity)?.refreshRechargeTxt()
+ binding.rvRecharge.setRechargeProduct(it?.productList) { item ->
+ (activity as? WalletActivity)?.refreshRechargeTxt()
+ }
+ }
+ }
+ }
+
+ override fun subscribeUi() {
+ EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().observe(this) {
+ binding.balance.setPrice(formatPrice(it?.balance))
+ }
+ }
+
+ companion object {
+ fun newInstance(): RechargeFragment {
+ return ARouter.getInstance().build(Routers.RECHARGE)
+ .navigation() as RechargeFragment
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/transaction/TransactionAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/transaction/TransactionAdapter.kt
new file mode 100644
index 0000000..d765064
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/transaction/TransactionAdapter.kt
@@ -0,0 +1,73 @@
+package com.remax.visualnovel.ui.wallet.transaction
+
+/**
+ * Created by HJW on 2025/9/12
+ */
+
+import com.chad.library.adapter.base.module.LoadMoreModule
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.BaseBindingQuickAdapter
+import com.remax.visualnovel.app.widget.setMyEmptyView
+import com.remax.visualnovel.databinding.ItemIncomeBinding
+import com.remax.visualnovel.entity.request.PageQuery
+import com.remax.visualnovel.entity.response.AccountBuffBill
+import com.remax.visualnovel.entity.response.TransactionGift
+import com.remax.visualnovel.extension.convertFromJson
+import com.remax.visualnovel.extension.formatPrice
+import com.remax.visualnovel.extension.glide.load
+import com.remax.visualnovel.manager.gift.GiftManager
+import com.remax.visualnovel.utils.TimeUtils
+import com.remax.visualnovel.widget.uitoken.changeTextColor
+
+/**
+ * 这是一个适配器,LoadMoreModule按需取舍
+ */
+class TransactionAdapter(private val type: String? = null) :
+ BaseBindingQuickAdapter(ItemIncomeBinding::inflate),
+ LoadMoreModule {
+
+ override fun convert(holder: BaseBindingHolder, item: AccountBuffBill) {
+ with(holder.getViewBinding()) {
+ timeIncome.text = TimeUtils.format_y_m_d_h_m_s(item.time)
+ titleIncome.text = item.item
+ ivIncome.setImageResource(R.mipmap.ic_transaction)
+ GiftManager.gifts?.find { it.id == item.extend.convertFromJson()?.giftId }?.let { gift ->
+ ivIncome.load(gift.icon)
+ }
+
+ when {
+ type == AccountBuffBill.INCOME -> {
+ priceIncome.setPrice("+${formatPrice(item.amount)}")
+ }
+
+ item.isIn -> {
+ priceIncome.getContentView()?.changeTextColor {
+ textUIColorToken = context.getString(R.string.color_positive_normal)
+ }
+ priceIncome.setPrice("+${formatPrice(item.amount)}")
+ }
+
+ else -> {
+ priceIncome.getContentView()?.changeTextColor {
+ textUIColorToken = context.getString(R.string.color_important_variant_normal)
+ }
+ priceIncome.setPrice("-${formatPrice(item.amount)}")
+ }
+ }
+ }
+ }
+
+ fun setTransactionData(isRefresh: Boolean, data: List, topMargin: Int) {
+ if (isRefresh) {
+ setList(data)
+ setMyEmptyView(R.string.no_transaction_yet, topMargin = topMargin)
+ } else {
+ addData(data)
+ loadMoreModule.loadMoreComplete()
+ }
+ if (data.size < PageQuery.DEFAULT_PAGE_SIZE) {
+ loadMoreModule.loadMoreEnd()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/transaction/TransactionDetailActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/transaction/TransactionDetailActivity.kt
new file mode 100644
index 0000000..569ee3c
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/wallet/transaction/TransactionDetailActivity.kt
@@ -0,0 +1,82 @@
+package com.remax.visualnovel.ui.wallet.transaction
+
+/**
+ * Created by HJW on 2025/9/12
+ */
+import androidx.activity.viewModels
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.alibaba.android.arouter.facade.annotation.Route
+import com.alibaba.android.arouter.launcher.ARouter
+import com.remax.visualnovel.R
+import com.remax.visualnovel.app.base.BaseBindingActivity
+import com.remax.visualnovel.databinding.ActivityTransactionBinding
+import com.remax.visualnovel.extension.addScrollerAlpha
+import com.remax.visualnovel.extension.autoRefreshList
+import com.remax.visualnovel.extension.launchAndCollect
+import com.remax.visualnovel.ui.wallet.WalletViewModel
+import com.remax.visualnovel.utils.Routers
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.itemdecoration.SpaceItemDecoration
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+@Route(path = Routers.TRANSACTION)
+class TransactionDetailActivity : BaseBindingActivity() {
+
+ private val walletViewModel by viewModels()
+
+ private val transactionAdapter by lazy {
+ TransactionAdapter()
+ }
+
+ override fun initView() {
+ setToolbar(R.string.transaction_detail)
+ with(binding) {
+ appBarLayout.addScrollerAlpha(tvTitle, isFromToolbar = true)
+ refreshLayout.setOnRefreshListener {
+ getData(true)
+ }
+ with(recyclerView) {
+ adapter = transactionAdapter
+ layoutManager = LinearLayoutManager(recyclerView.context)
+ addItemDecoration(SpaceItemDecoration(16.dp, 24.dp,0,24.dp))
+ with(transactionAdapter) {
+ setOnItemClickListener { _, _, position ->
+
+ }
+ loadMoreModule.setOnLoadMoreListener {
+ getData(false)
+ }
+ }
+ }
+ }
+ }
+
+ override fun initData() {
+ binding.refreshLayout.autoRefreshList()
+ }
+
+ private fun getData(isRefresh: Boolean) {
+ launchAndCollect({ walletViewModel.getBillList(isRefresh, null) }) {
+ onSuccess = {
+ val data = it?.pageList?.datas ?: emptyList()
+ transactionAdapter.setTransactionData(isRefresh, data, 120)
+ }
+
+ onComplete = {
+ if (isRefresh) {
+ binding.refreshLayout.finishRefresh()
+ }
+ }
+ }
+ }
+
+ companion object {
+ fun start() {
+ ARouter.getInstance()
+ .build(Routers.TRANSACTION)
+ .navigation()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/RecordHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/RecordHelper.kt
new file mode 100644
index 0000000..d6ad93c
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/RecordHelper.kt
@@ -0,0 +1,91 @@
+package com.remax.visualnovel.utils
+
+import android.app.Activity
+import android.os.Environment
+import com.buihha.audiorecorder.Mp3Recorder
+import com.buihha.audiorecorder.Mp3Recorder.OnRecordListener
+import timber.log.Timber
+import java.io.File
+
+/**
+ * Created by HJW on 2025/8/16
+ */
+class RecordHelper {
+
+ private var recorder: Mp3Recorder? = null
+ private var filename: String = "null"
+
+ fun isRecording(): Boolean {
+ return recorder != null && recorder!!.isRecording()
+ }
+
+ @Synchronized
+ fun startRecording(activity: Activity, onStart: () -> Unit, onStop: () -> Unit) {
+ try {
+ recorder = Mp3Recorder()
+ recorder!!.setOnRecordListener(object : OnRecordListener {
+ override fun onStart() {
+ //开始录音
+ onStart.invoke()
+ }
+
+ override fun onStop() {
+ //停止录音
+ onStop.invoke()
+ }
+
+ override fun onError() {
+ Timber.i("startRecording onError")
+ //录音错误,主要针对OPPO手机在调用startRecord方法时弹窗安全权限提示,此时如果拒绝,则会执行该回调
+ onStop.invoke()
+ }
+
+ override fun onRecording(i: Int, v: Double) {
+ //Timber.d("采样:" + i + "Hz 音量:" + v + "分贝");
+ }
+ })
+ if (!recorder!!.isRecording()) {
+ val filePath = activity.getExternalFilesDir(Environment.DIRECTORY_MUSIC)
+ val file = File(
+ filePath,
+ "record_${System.currentTimeMillis()}.mp3"
+ )
+ filename = file.path
+ recorder!!.startRecording(filePath?.path, file.getName())
+ }
+ } catch (e: Exception) {
+ Timber.i("startRecording catch :${e.localizedMessage}")
+ e.printStackTrace()
+ onStop.invoke()
+ }
+ }
+
+ @Synchronized
+ fun stopRecording() {
+ try {
+ if (recorder != null && recorder!!.isRecording()) {
+ recorder!!.stopRecording()
+ recorder = null
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ Timber.i("startRecording stopRecording :${e.localizedMessage}")
+
+ recorder = null
+
+ val file = File(filename)
+ if (file.exists()) {
+ file.delete()
+ }
+ }
+ }
+
+ fun getFilename(): String {
+ return filename
+ }
+
+ fun setFilename(filename: String) {
+ this.filename = filename
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt
index 77e0a08..ba6b05b 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt
@@ -13,7 +13,17 @@ class Routers {
private const val ROUTER = "/router/"
+ //----------------- Pre ----------------
+ const val CHAT_FLIRTING_LEVEL = "${ROUTER}chatFlirtingLevel"
+ const val WALLET = "${ROUTER}wallet"
+ const val INCOME = "${ROUTER}income"
+ const val TRANSACTION = "${ROUTER}transaction"
+ const val RECHARGE = "${ROUTER}recharge"
+
+
+
+ //----------------- New ----------------
const val SPLASH = "${ROUTER}splash"
const val MAIN = "${ROUTER}main"
const val ROUTE_FRAG_BOOKLIST = "${ROUTER}bookList"
@@ -39,6 +49,10 @@ class Routers {
navigationToBrowser(url = BuildConfig.EPAL_TERMS_SERVICES)
}
+ fun navigationToRA() {
+ navigationToBrowser(url = BuildConfig.RECHAEGE_SERVICES)
+ }
+
fun navigationToBrowser(title: String = "", url: String, needTitle: Boolean = true) {
ARouter.getInstance()
.build(BROWSER)
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/analytics/AnalyticsEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/analytics/AnalyticsEvent.kt
new file mode 100644
index 0000000..b98a77e
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/analytics/AnalyticsEvent.kt
@@ -0,0 +1,10 @@
+package com.remax.visualnovel.utils.analytics
+
+/**
+ * Created by HJW on 2020/9/23
+ * 埋点事件
+ */
+object AnalyticsEvent {
+
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/analytics/AnalyticsUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/analytics/AnalyticsUtils.kt
new file mode 100644
index 0000000..5e3ee79
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/analytics/AnalyticsUtils.kt
@@ -0,0 +1,38 @@
+package com.remax.visualnovel.utils.analytics
+
+import android.content.Context
+import android.os.Bundle
+import com.remax.visualnovel.manager.login.LoginManager
+import com.google.firebase.analytics.FirebaseAnalytics
+import timber.log.Timber
+
+object AnalyticsUtils {
+
+ private lateinit var firebaseAnalytics: FirebaseAnalytics
+
+ fun init(context: Context) {
+ if (!this::firebaseAnalytics.isInitialized) {
+ firebaseAnalytics = FirebaseAnalytics.getInstance(context)
+ }
+ refreshUserId()
+ }
+
+ fun refreshUserId() {
+ Timber.d("firebase埋点更新userId:${LoginManager.user?.userId}")
+ if (this::firebaseAnalytics.isInitialized) {
+ firebaseAnalytics.setUserId(LoginManager.user?.userId?:"")
+ }
+ }
+
+ /**
+ * 添加埋点
+ */
+ fun logAnalytics(event: String, bundle: Bundle? = null) {
+ Timber.d("埋点事件:$event")
+ if (this::firebaseAnalytics.isInitialized) {
+ Timber.d("firebase埋点 event:$event bundle:${bundle.toString()}")
+ firebaseAnalytics.logEvent(event, bundle)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/RoundFrameLayout.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/RoundFrameLayout.java
new file mode 100644
index 0000000..105d2d1
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/RoundFrameLayout.java
@@ -0,0 +1,183 @@
+package com.remax.visualnovel.widget;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+
+import com.remax.visualnovel.R;
+
+/**
+ * 裁掉圆角边的父布局
+ */
+public class RoundFrameLayout extends FrameLayout {
+
+ private float topLeftRadius;
+ private float topRightRadius;
+ private float bottomLeftRadius;
+ private float bottomRightRadius;
+
+ private Paint roundPaint;
+ private Paint imagePaint;
+
+ public RoundFrameLayout(Context context) {
+ this(context, null);
+ }
+
+ public RoundFrameLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public RoundFrameLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ if (attrs != null) {
+ TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundFrameLayout);
+ float radius = ta.getDimension(R.styleable.RoundFrameLayout_radius, 0);
+ topLeftRadius = ta.getDimension(R.styleable.RoundFrameLayout_topLeftRadius, radius);
+ topRightRadius = ta.getDimension(R.styleable.RoundFrameLayout_topRightRadius, radius);
+ bottomLeftRadius = ta.getDimension(R.styleable.RoundFrameLayout_bottomLeftRadius, radius);
+ bottomRightRadius = ta.getDimension(R.styleable.RoundFrameLayout_bottomRightRadius, radius);
+ ta.recycle();
+ }
+ roundPaint = new Paint();
+ roundPaint.setColor(Color.WHITE);
+ roundPaint.setAntiAlias(true);
+ roundPaint.setStyle(Paint.Style.FILL);
+ roundPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
+
+ imagePaint = new Paint();
+ imagePaint.setXfermode(null);
+ }
+
+ public void setRadius(float radius) {
+ topLeftRadius = radius;
+ topRightRadius = radius;
+ bottomLeftRadius = radius;
+ bottomRightRadius = radius;
+ }
+
+ public void setRadius(float topLeftRadius, float topRightRadius, float bottomLeftRadius, float bottomRightRadius) {
+ this.topLeftRadius = topLeftRadius;
+ this.topRightRadius = topRightRadius;
+ this.bottomLeftRadius = bottomLeftRadius;
+ this.bottomRightRadius = bottomRightRadius;
+ }
+
+ //实现1
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ int width = getWidth();
+ int height = getHeight();
+ Path path = new Path();
+ path.moveTo(0, topLeftRadius);
+ path.arcTo(new RectF(0, 0, topLeftRadius * 2, topLeftRadius * 2), -180, 90);
+ path.lineTo(width - topRightRadius, 0);
+ path.arcTo(new RectF(width - 2 * topRightRadius, 0, width, topRightRadius * 2), -90, 90);
+ path.lineTo(width, height - bottomRightRadius);
+ path.arcTo(new RectF(width - 2 * bottomRightRadius, height - 2 * bottomRightRadius, width, height), 0, 90);
+ path.lineTo(bottomLeftRadius, height);
+ path.arcTo(new RectF(0, height - 2 * bottomLeftRadius, bottomLeftRadius * 2, height), 90, 90);
+ path.close();
+ canvas.clipPath(path);
+ super.dispatchDraw(canvas);
+ }
+
+// //实现2
+// @Override
+// protected void dispatchDraw(Canvas canvas) {
+// super.dispatchDraw(canvas);
+// drawTopLeft(canvas);//用PorterDuffXfermode
+// drawTopRight(canvas);//用PorterDuffXfermode
+// drawBottomLeft(canvas);//用PorterDuffXfermode
+// drawBottomRight(canvas);//用PorterDuffXfermode
+// }
+
+// //实现3
+// @Override
+// protected void dispatchDraw(Canvas canvas) {
+// Bitmap bitmap = Bitmap.createBitmap(canvas.getWidth(), canvas.getHeight(), Bitmap.Config.ARGB_8888);
+// Canvas newCanvas = new Canvas(bitmap);
+// super.dispatchDraw(newCanvas);
+// drawTopLeft(newCanvas);
+// drawTopRight(newCanvas);
+// drawBottomLeft(newCanvas);
+// drawBottomRight(newCanvas);
+// canvas.drawBitmap(bitmap, 0, 0, imagePaint);
+//// invalidate();
+// }
+
+// //实现4
+// @Override
+// protected void dispatchDraw(Canvas canvas) {
+// canvas.saveLayer(new RectF(0, 0, canvas.getWidth(), canvas.getHeight()), imagePaint, Canvas.ALL_SAVE_FLAG);
+// super.dispatchDraw(canvas);
+// drawTopLeft(canvas);
+// drawTopRight(canvas);
+// drawBottomLeft(canvas);
+// drawBottomRight(canvas);
+// canvas.restore();
+// }
+
+ private void drawTopLeft(Canvas canvas) {
+ if (topLeftRadius > 0) {
+ Path path = new Path();
+ path.moveTo(0, topLeftRadius);
+ path.lineTo(0, 0);
+ path.lineTo(topLeftRadius, 0);
+ path.arcTo(new RectF(0, 0, topLeftRadius * 2, topLeftRadius * 2),
+ -90, -90);
+ path.close();
+ canvas.drawPath(path, roundPaint);
+ }
+ }
+
+ private void drawTopRight(Canvas canvas) {
+ if (topRightRadius > 0) {
+ int width = getWidth();
+ Path path = new Path();
+ path.moveTo(width - topRightRadius, 0);
+ path.lineTo(width, 0);
+ path.lineTo(width, topRightRadius);
+ path.arcTo(new RectF(width - 2 * topRightRadius, 0, width,
+ topRightRadius * 2), 0, -90);
+ path.close();
+ canvas.drawPath(path, roundPaint);
+ }
+ }
+
+ private void drawBottomLeft(Canvas canvas) {
+ if (bottomLeftRadius > 0) {
+ int height = getHeight();
+ Path path = new Path();
+ path.moveTo(0, height - bottomLeftRadius);
+ path.lineTo(0, height);
+ path.lineTo(bottomLeftRadius, height);
+ path.arcTo(new RectF(0, height - 2 * bottomLeftRadius,
+ bottomLeftRadius * 2, height), 90, 90);
+ path.close();
+ canvas.drawPath(path, roundPaint);
+ }
+ }
+
+ private void drawBottomRight(Canvas canvas) {
+ if (bottomRightRadius > 0) {
+ int height = getHeight();
+ int width = getWidth();
+ Path path = new Path();
+ path.moveTo(width - bottomRightRadius, height);
+ path.lineTo(width, height);
+ path.lineTo(width, height - bottomRightRadius);
+ path.arcTo(new RectF(width - 2 * bottomRightRadius, height - 2
+ * bottomRightRadius, width, height), 0, 90);
+ path.close();
+ canvas.drawPath(path, roundPaint);
+ }
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryNavigatorAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryNavigatorAdapter.kt
new file mode 100644
index 0000000..696c130
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryNavigatorAdapter.kt
@@ -0,0 +1,64 @@
+package com.remax.visualnovel.widget.indicator
+
+import android.content.Context
+import androidx.viewpager.widget.ViewPager
+import androidx.viewpager2.widget.ViewPager2
+import com.remax.visualnovel.app.base.BaseCommonNavigatorAdapter
+import com.remax.visualnovel.extension.setOnClick
+import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerIndicator
+import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerTitleView
+
+class SecondaryNavigatorAdapter(
+ private val mTitleList: List,
+ override val viewPager2: ViewPager2? = null,
+ override val viewPager: ViewPager? = null,
+ private val lockList: List? = null,
+) : BaseCommonNavigatorAdapter(viewPager2, viewPager) {
+
+ var indexClickBlock: ((Int) -> Unit)? = null
+
+ override fun getCount(): Int {
+ return mTitleList.size
+ }
+
+ override fun getTitleView(context: Context, index: Int): IPagerTitleView {
+ val secondaryPagerTitleView =
+ if (lockList == null) {
+ SecondaryPagerTitleView(context)
+ } else {
+ SecondaryPagerLockedTitleView(context)
+ }
+
+
+ (secondaryPagerTitleView as? SecondaryPagerLockedTitleView)?.let {
+ val isLock = lockList?.getOrNull(index) == true
+ it.setIcon(isLock)
+ it.getTitleTextView().text = mTitleList[index]
+ }
+
+ (secondaryPagerTitleView as? SecondaryPagerMoreTitleView)?.let {
+ it.getTitleTextView().text = mTitleList[index]
+ }
+
+ (secondaryPagerTitleView as? SecondaryPagerTitleView)?.let {
+ it.getTitleTextView().text = mTitleList[index]
+ }
+
+ setOnClick(secondaryPagerTitleView) {
+ indexClickBlock?.invoke(index)
+ viewPager2?.let {
+ it.currentItem = index
+ }
+ viewPager?.let {
+ it.currentItem = index
+ }
+ }
+
+
+ return secondaryPagerTitleView
+ }
+
+ override fun getIndicator(context: Context): IPagerIndicator? {
+ return null
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryNavigatorDictAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryNavigatorDictAdapter.kt
new file mode 100644
index 0000000..68053ef
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryNavigatorDictAdapter.kt
@@ -0,0 +1,48 @@
+package com.remax.visualnovel.widget.indicator
+
+import android.annotation.SuppressLint
+import android.content.Context
+import com.remax.visualnovel.app.base.BaseCommonNavigatorAdapter
+import com.remax.visualnovel.entity.response.AIDictItem
+import com.remax.visualnovel.extension.setOnClick
+import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerIndicator
+import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerTitleView
+
+class SecondaryNavigatorDictAdapter(
+ private val mTitleList: List,
+) : BaseCommonNavigatorAdapter(null, null) {
+
+ var indexClickBlock: ((Int) -> Unit)? = null
+
+ override fun getCount(): Int {
+ return mTitleList.size
+ }
+
+ @SuppressLint("SetTextI18n")
+ override fun getTitleView(context: Context, index: Int): IPagerTitleView {
+ val secondaryPagerTitleView = SecondaryPagerTitleView(context)
+ val currItem = mTitleList[index]
+ secondaryPagerTitleView.getTitleTextView().text = "#${currItem.name}"
+ if (currItem.select) {
+ secondaryPagerTitleView.onSelected(index, mTitleList.size)
+ } else {
+ secondaryPagerTitleView.onDeselected(index, mTitleList.size)
+ }
+ setOnClick(secondaryPagerTitleView) {
+ mTitleList.forEach { tag ->
+ if (tag == currItem) tag.select = !tag.select
+ }
+ if (currItem.select) {
+ secondaryPagerTitleView.onSelected(index, mTitleList.size)
+ } else {
+ secondaryPagerTitleView.onDeselected(index, mTitleList.size)
+ }
+ indexClickBlock?.invoke(index)
+ }
+ return secondaryPagerTitleView
+ }
+
+ override fun getIndicator(context: Context): IPagerIndicator? {
+ return null
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerLockedTitleView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerLockedTitleView.kt
new file mode 100644
index 0000000..17d5a9d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerLockedTitleView.kt
@@ -0,0 +1,156 @@
+package com.remax.visualnovel.widget.indicator
+
+import android.content.Context
+import android.graphics.Rect
+import android.text.TextUtils
+import android.view.Gravity
+import android.widget.FrameLayout
+import androidx.appcompat.widget.AppCompatTextView
+import com.remax.visualnovel.R
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.ui.IconFontTextView
+import com.remax.visualnovel.widget.uitoken.changeBackground
+import com.remax.visualnovel.widget.uitoken.changeTextColor
+import com.remax.visualnovel.widget.uitoken.changeTextFont
+import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IMeasurablePagerTitleView
+
+class SecondaryPagerLockedTitleView(context: Context) : FrameLayout(context, null), IMeasurablePagerTitleView {
+
+ private lateinit var titleTextView: IconFontTextView
+ private var isActive = false
+
+ init {
+ init(context)
+ }
+
+ private fun init(context: Context) {
+ titleTextView = IconFontTextView(context)
+ addView(titleTextView)
+ with(titleTextView) {
+ setPaddingRelative(16.dp, 0, 16.dp, 0)
+ setSingleLine()
+ ellipsize = TextUtils.TruncateAt.END
+ includeFontPadding = false
+ height = 32.dp
+ gravity = Gravity.CENTER
+ changeTextFont {
+ textUITextToken = context.getString(R.string.txt_label_m)
+ }
+ }
+
+ setPaddingRelative(0, 0, 12.dp, 0)
+ }
+
+ fun setIcon(isLock: Boolean) {
+ titleTextView.setIconFontDrawable(
+ endIconFont = if (isLock) context.getString(R.string.icon_private) else null,
+ iconColorToken = context.getString(R.string.color_txt_primary_normal),
+ iconSize = 16, iconPadding = 4
+ )
+ }
+
+ private fun setActiveStatus(isActive: Boolean) {
+ this.isActive = isActive
+ with(titleTextView) {
+ if (isActive) {
+ changeTextColor {
+ textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal)
+ textUIPressedColorToken = context.getString(R.string.color_txt_primary_specialmap_normal)
+ }
+
+ changeBackground {
+ radiusToken = context.getString(R.string.radius_pill)
+
+ strokeUIWidthToken = context.getString(R.string.border_s)
+
+ backgroundUIColorToken = context.getString(R.string.color_primary_onpic_normal)
+ strokeUIColorToken = context.getString(R.string.color_primary_variant_normal)
+
+ backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled)
+ strokeUIDisabledColorToken = context.getString(R.string.color_outline_disabled)
+
+ backgroundUIPressedColorToken = context.getString(R.string.color_primary_onpic_press)
+ strokeUIPressedColorToken = context.getString(R.string.color_primary_variant_normal)
+ }
+
+ } else {
+ changeTextColor {
+ textUIColorToken = context.getString(R.string.color_txt_primary_normal)
+ textUIPressedColorToken = context.getString(R.string.color_txt_primary_normal)
+ textUIDisabledColorToken = context.getString(R.string.color_txt_disabled)
+ }
+
+ changeBackground {
+ radiusToken = context.getString(R.string.radius_pill)
+ //无筛选状态将描边恢复默认
+ strokeUIWidthToken = ""
+
+ backgroundUIColorToken = context.getString(R.string.color_surface_element_normal)
+
+ backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled)
+
+ backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press)
+ }
+ }
+ }
+
+ }
+
+ override fun onSelected(index: Int, totalCount: Int) {
+ setActiveStatus(true)
+ }
+
+ override fun onDeselected(index: Int, totalCount: Int) {
+ setActiveStatus(false)
+ }
+
+ override fun onLeave(index: Int, totalCount: Int, leavePercent: Float, leftToRight: Boolean) {}
+ override fun onEnter(index: Int, totalCount: Int, enterPercent: Float, leftToRight: Boolean) {}
+ override fun getContentLeft(): Int {
+ val bound = Rect()
+ var longestString = ""
+ if (titleTextView.text.toString().contains("\n")) {
+ val brokenStrings = titleTextView.text.toString().split("\\n".toRegex()).toTypedArray()
+ for (each in brokenStrings) {
+ if (each.length > longestString.length) longestString = each
+ }
+ } else {
+ longestString = titleTextView.text.toString()
+ }
+ titleTextView.paint.getTextBounds(longestString, 0, longestString.length, bound)
+ val contentWidth = bound.width()
+ return left + width / 2 - contentWidth / 2
+ }
+
+ override fun getContentTop(): Int {
+ val metrics = titleTextView.paint.fontMetrics
+ val contentHeight = metrics.bottom - metrics.top
+ return (height / 2 - contentHeight / 2).toInt()
+ }
+
+ override fun getContentRight(): Int {
+ val bound = Rect()
+ var longestString = ""
+ if (titleTextView.text.toString().contains("\n")) {
+ val brokenStrings = titleTextView.text.toString().split("\\n".toRegex()).toTypedArray()
+ for (each in brokenStrings) {
+ if (each.length > longestString.length) longestString = each
+ }
+ } else {
+ longestString = titleTextView.text.toString()
+ }
+ titleTextView.paint.getTextBounds(longestString, 0, longestString.length, bound)
+ val contentWidth = bound.width()
+ return left + width / 2 + contentWidth / 2
+ }
+
+ override fun getContentBottom(): Int {
+ val metrics = titleTextView.paint.fontMetrics
+ val contentHeight = metrics.bottom - metrics.top
+ return (height / 2 + contentHeight / 2).toInt()
+ }
+
+ fun getTitleTextView(): AppCompatTextView {
+ return titleTextView
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerMoreTitleView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerMoreTitleView.kt
new file mode 100644
index 0000000..a155d4d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerMoreTitleView.kt
@@ -0,0 +1,102 @@
+package com.remax.visualnovel.widget.indicator
+
+import android.content.Context
+import android.graphics.Rect
+import android.text.TextUtils
+import android.view.Gravity
+import android.widget.FrameLayout
+import androidx.appcompat.widget.AppCompatTextView
+import com.remax.visualnovel.R
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.ui.IconFontTextView
+import com.remax.visualnovel.widget.uitoken.changeTextStyle
+import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IMeasurablePagerTitleView
+
+class SecondaryPagerMoreTitleView(context: Context) : FrameLayout(context, null), IMeasurablePagerTitleView {
+
+ private lateinit var titleTextView: IconFontTextView
+
+ init {
+ init(context)
+ }
+
+ private fun init(context: Context) {
+ titleTextView = IconFontTextView(context)
+ addView(titleTextView)
+ with(titleTextView) {
+ setPaddingRelative(16.dp, 0, 16.dp, 0)
+ setSingleLine()
+ ellipsize = TextUtils.TruncateAt.END
+ includeFontPadding = false
+ height = 32.dp
+ gravity = Gravity.CENTER
+ setIconFontDrawable(
+ endIconFont = context.getString(R.string.icon_arrow_down_fill),
+ iconColorToken = context.getString(R.string.color_txt_primary_normal),
+ iconSize = 16, iconPadding = 4
+ )
+ changeTextStyle {
+ textUITextToken = context.getString(R.string.txt_label_m)
+ textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal)
+ }
+ }
+ }
+
+ override fun onSelected(index: Int, totalCount: Int) {
+
+ }
+
+ override fun onDeselected(index: Int, totalCount: Int) {
+
+ }
+
+ override fun onLeave(index: Int, totalCount: Int, leavePercent: Float, leftToRight: Boolean) {}
+ override fun onEnter(index: Int, totalCount: Int, enterPercent: Float, leftToRight: Boolean) {}
+ override fun getContentLeft(): Int {
+ val bound = Rect()
+ var longestString = ""
+ if (titleTextView.text.toString().contains("\n")) {
+ val brokenStrings = titleTextView.text.toString().split("\\n".toRegex()).toTypedArray()
+ for (each in brokenStrings) {
+ if (each.length > longestString.length) longestString = each
+ }
+ } else {
+ longestString = titleTextView.text.toString()
+ }
+ titleTextView.paint.getTextBounds(longestString, 0, longestString.length, bound)
+ val contentWidth = bound.width()
+ return left + width / 2 - contentWidth / 2
+ }
+
+ override fun getContentTop(): Int {
+ val metrics = titleTextView.paint.fontMetrics
+ val contentHeight = metrics.bottom - metrics.top
+ return (height / 2 - contentHeight / 2).toInt()
+ }
+
+ override fun getContentRight(): Int {
+ val bound = Rect()
+ var longestString = ""
+ if (titleTextView.text.toString().contains("\n")) {
+ val brokenStrings = titleTextView.text.toString().split("\\n".toRegex()).toTypedArray()
+ for (each in brokenStrings) {
+ if (each.length > longestString.length) longestString = each
+ }
+ } else {
+ longestString = titleTextView.text.toString()
+ }
+ titleTextView.paint.getTextBounds(longestString, 0, longestString.length, bound)
+ val contentWidth = bound.width()
+ return left + width / 2 + contentWidth / 2
+ }
+
+ override fun getContentBottom(): Int {
+ val metrics = titleTextView.paint.fontMetrics
+ val contentHeight = metrics.bottom - metrics.top
+ return (height / 2 + contentHeight / 2).toInt()
+ }
+
+ fun getTitleTextView(): AppCompatTextView {
+ return titleTextView
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerTitleView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerTitleView.kt
new file mode 100644
index 0000000..2a4f39d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/SecondaryPagerTitleView.kt
@@ -0,0 +1,82 @@
+package com.remax.visualnovel.widget.indicator
+
+import android.content.Context
+import android.graphics.Rect
+import android.widget.FrameLayout
+import androidx.appcompat.widget.AppCompatTextView
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.ui.chips.FilterChipView
+import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IMeasurablePagerTitleView
+
+class SecondaryPagerTitleView(context: Context) : FrameLayout(context, null), IMeasurablePagerTitleView {
+
+ private lateinit var titleTextView: FilterChipView
+
+ init {
+ init(context)
+ }
+
+ private fun init(context: Context) {
+ titleTextView = FilterChipView(context)
+ addView(titleTextView)
+ setPaddingRelative(0, 0, 12.dp, 0)
+ }
+
+ override fun onSelected(index: Int, totalCount: Int) {
+ titleTextView.setActive()
+ }
+
+ override fun onDeselected(index: Int, totalCount: Int) {
+ titleTextView.setInActive()
+ }
+
+ override fun onLeave(index: Int, totalCount: Int, leavePercent: Float, leftToRight: Boolean) {}
+ override fun onEnter(index: Int, totalCount: Int, enterPercent: Float, leftToRight: Boolean) {}
+ override fun getContentLeft(): Int {
+ val bound = Rect()
+ var longestString = ""
+ if (titleTextView.text.toString().contains("\n")) {
+ val brokenStrings = titleTextView.text.toString().split("\\n".toRegex()).toTypedArray()
+ for (each in brokenStrings) {
+ if (each.length > longestString.length) longestString = each
+ }
+ } else {
+ longestString = titleTextView.text.toString()
+ }
+ titleTextView.paint.getTextBounds(longestString, 0, longestString.length, bound)
+ val contentWidth = bound.width()
+ return left + width / 2 - contentWidth / 2
+ }
+
+ override fun getContentTop(): Int {
+ val metrics = titleTextView.paint.fontMetrics
+ val contentHeight = metrics.bottom - metrics.top
+ return (height / 2 - contentHeight / 2).toInt()
+ }
+
+ override fun getContentRight(): Int {
+ val bound = Rect()
+ var longestString = ""
+ if (titleTextView.text.toString().contains("\n")) {
+ val brokenStrings = titleTextView.text.toString().split("\\n".toRegex()).toTypedArray()
+ for (each in brokenStrings) {
+ if (each.length > longestString.length) longestString = each
+ }
+ } else {
+ longestString = titleTextView.text.toString()
+ }
+ titleTextView.paint.getTextBounds(longestString, 0, longestString.length, bound)
+ val contentWidth = bound.width()
+ return left + width / 2 + contentWidth / 2
+ }
+
+ override fun getContentBottom(): Int {
+ val metrics = titleTextView.paint.fontMetrics
+ val contentHeight = metrics.bottom - metrics.top
+ return (height / 2 + contentHeight / 2).toInt()
+ }
+
+ fun getTitleTextView(): AppCompatTextView {
+ return titleTextView
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt
index 8cfce0f..3c2cc05 100644
--- a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt
@@ -1,4 +1,4 @@
-package com.crushlevel.android.widget.itemdecoration
+package com.remax.visualnovel.widget.itemdecoration
import android.graphics.Rect
import android.view.View
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/AssistChipView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/AssistChipView.kt
new file mode 100644
index 0000000..b79bd5d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/AssistChipView.kt
@@ -0,0 +1,38 @@
+package com.remax.visualnovel.widget.ui.chips
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.Gravity
+import com.remax.visualnovel.R
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.ui.IconFontTextView
+import com.remax.visualnovel.widget.uitoken.changeBackground
+import com.remax.visualnovel.widget.uitoken.changeTextStyle
+
+/**
+ * create by wl on 2022/9/9
+ * 帮助chip
+ */
+class AssistChipView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : IconFontTextView(context, attrs, defStyleAttr) {
+
+ init {
+ //初始化固定token
+ changeTextStyle {
+ textUITextToken = context.getString(R.string.txt_label_m)
+ textUIColorToken = context.getString(R.string.color_txt_primary_normal)
+ textUIDisabledColorToken = context.getString(R.string.color_txt_disabled)
+ textUIPressedColorToken = context.getString(R.string.color_txt_primary_normal)
+ }
+ changeBackground {
+ backgroundUIColorToken = context.getString(R.string.color_surface_element_normal)
+ backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled)
+ backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press)
+ radiusToken = context.getString(R.string.radius_round)
+ }
+ gravity = Gravity.CENTER
+ height = 32.dp
+ setPaddingRelative(12.dp, 0, 16.dp, 0)
+ }
+
+
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/DropdownChipView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/DropdownChipView.kt
new file mode 100644
index 0000000..176d12d
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/DropdownChipView.kt
@@ -0,0 +1,57 @@
+package com.remax.visualnovel.widget.ui.chips
+
+import android.content.Context
+import android.util.AttributeSet
+import com.remax.visualnovel.R
+import com.remax.visualnovel.widget.ui.IconFontTextView
+import com.remax.visualnovel.widget.uitoken.changeBackground
+import com.remax.visualnovel.widget.uitoken.changeTextColor
+import com.remax.visualnovel.widget.uitoken.changeTextFont
+import com.scwang.smart.refresh.layout.util.SmartUtil
+
+class DropdownChipView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : IconFontTextView(context, attrs, defStyleAttr) {
+
+
+ init {
+ //初始化固定token
+ customViewToken.run {
+ textUITextToken = context.getString(R.string.txt_label_m)
+ textUIDisabledColorToken = context.getString(R.string.color_txt_disabled)
+ backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press)
+ radiusToken = context.getString(R.string.radius_round)
+ }
+ changeTextFont(customViewToken)
+ setInActive()
+ setPaddingRelative(SmartUtil.dp2px(16f), SmartUtil.dp2px(6f), SmartUtil.dp2px(8f), SmartUtil.dp2px(6f))
+ }
+
+ /**
+ * 筛选状态
+ */
+ fun setActive() {
+ customViewToken.run {
+ //normal
+ textUIColorToken = context.getString(R.string.color_primary_variant_normal)
+ //press
+ textUIPressedColorToken = context.getString(R.string.color_primary_variant_normal)
+ setIconFontDrawable(endIconFont = context.getString(R.string.icon_arrow_down_fill), iconPadding = 4, iconSize = 12)
+ changeTextColor(customViewToken)
+ changeBackground(customViewToken)
+ }
+ }
+
+ /**
+ * 无筛选状态
+ */
+ fun setInActive() {
+ customViewToken.run {
+ //normal
+ textUIColorToken = context.getString(R.string.color_txt_primary_normal)
+ //press
+ textUIPressedColorToken = context.getString(R.string.color_txt_primary_normal)
+ setIconFontDrawable(endIconFont = context.getString(R.string.icon_arrow_down_fill), iconPadding = 4, iconSize = 12)
+ changeTextColor(customViewToken)
+ changeBackground(customViewToken)
+ }
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/FilterChipView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/FilterChipView.kt
new file mode 100644
index 0000000..ee18e52
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/FilterChipView.kt
@@ -0,0 +1,103 @@
+package com.remax.visualnovel.widget.ui.chips
+
+import android.content.Context
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.view.Gravity
+import com.remax.visualnovel.R
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.uitoken.changeBackground
+import com.remax.visualnovel.widget.uitoken.changeTextColor
+import com.remax.visualnovel.widget.uitoken.changeTextFont
+import com.remax.visualnovel.widget.uitoken.view.UITokenTextView
+
+/**
+ * create by wl on 2022/9/8
+ * 筛选chip
+ */
+class FilterChipView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : UITokenTextView(context, attrs, defStyleAttr) {
+
+ var isActive = false
+ private set
+
+ init {
+ //初始化固定token
+ customViewToken.run {
+ radiusToken = context.getString(R.string.radius_round)
+ textUITextToken = context.getString(R.string.txt_label_m)
+ textUIDisabledColorToken = context.getString(R.string.color_txt_disabled)
+ }
+ changeTextFont(customViewToken)
+ setInActive()
+ setPaddingRelative(16.dp, 0, 16.dp, 0)
+ setSingleLine()
+ ellipsize = TextUtils.TruncateAt.END
+ includeFontPadding = false
+ height = 32.dp
+ gravity = Gravity.CENTER
+ }
+
+ /**
+ * 筛选状态
+ */
+ fun setActive() {
+ isActive = true
+ setActiveStatus()
+ }
+
+ /**
+ * 无筛选状态
+ */
+ fun setInActive() {
+ isActive = false
+ setActiveStatus()
+ }
+
+ private fun setActiveStatus() {
+ if (isActive) {
+ changeTextColor {
+ textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal)
+ textUIPressedColorToken = context.getString(R.string.color_txt_primary_specialmap_normal)
+ }
+
+ changeBackground {
+ strokeUIWidthToken = context.getString(R.string.border_s)
+
+ backgroundUIColorToken = context.getString(R.string.color_primary_normal)
+ strokeUIColorToken = context.getString(R.string.color_outline_normal)
+
+ backgroundUIDisabledColorToken = context.getString(R.string.color_emphasis_disabled)
+ strokeUIDisabledColorToken = context.getString(R.string.color_outline_disabled)
+
+ backgroundUIPressedColorToken = context.getString(R.string.color_primary_press)
+ strokeUIPressedColorToken = context.getString(R.string.color_primary_variant_normal)
+ }
+
+ } else {
+ changeTextColor {
+ textUIColorToken = context.getString(R.string.color_txt_primary_normal)
+ textUIPressedColorToken = context.getString(R.string.color_txt_primary_normal)
+ textUIDisabledColorToken = context.getString(R.string.color_txt_disabled)
+ }
+
+ changeBackground {
+ strokeUIWidthToken = ""
+
+ backgroundUIColorToken = context.getString(R.string.color_surface_element_normal)
+
+ backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled)
+
+ backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press)
+ }
+ }
+ }
+
+ fun setActive(isActive: Boolean?) {
+ this.isActive = isActive == true
+ setActiveStatus()
+ }
+
+ fun getActive(): Boolean {
+ return isActive
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/RemovableChipView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/RemovableChipView.kt
new file mode 100644
index 0000000..807d062
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/chips/RemovableChipView.kt
@@ -0,0 +1,127 @@
+package com.remax.visualnovel.widget.ui.chips
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.core.content.withStyledAttributes
+import com.remax.visualnovel.R
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.ui.IconFontTextView
+import com.remax.visualnovel.widget.uitoken.changeBackground
+import com.remax.visualnovel.widget.uitoken.changeTextColor
+import com.remax.visualnovel.widget.uitoken.changeTextFont
+
+/**
+ * create by wl on 2022/9/9
+ * 筛选chip
+ */
+class RemovableChipView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : IconFontTextView(context, attrs, defStyleAttr) {
+
+ companion object {
+ const val START = 1
+ const val END = 2
+ }
+
+ private var iconDirection = END
+ private var iconFont: String = ""
+
+ init {
+ context.withStyledAttributes(attrs, R.styleable.RemovableChipView) {
+ iconFont = getString(R.styleable.RemovableChipView_iconFont) ?: context.getString(R.string.icon_delete_2)
+ iconDirection = getInt(R.styleable.RemovableChipView_chipIconDirection, END)
+ }
+
+ //初始化固定token
+ customViewToken.run {
+ textUITextToken = context.getString(R.string.txt_label_m)
+ textUIDisabledColorToken = context.getString(R.string.color_txt_disabled)
+ textUIPressedColorToken = context.getString(R.string.color_txt_primary_normal)
+ backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled)
+ radiusToken = context.getString(R.string.radius_round)
+ }
+ changeTextFont(customViewToken)
+ setInActive()
+ setPaddingRelative(16.dp, 6.dp, 12.dp, 6.dp)
+ }
+
+ /**
+ * 筛选状态
+ */
+ fun setActive() {
+ customViewToken.run {
+ strokeUIWidthToken = context.getString(R.string.border_s)
+ //normal
+ backgroundUIColorToken = context.getString(R.string.color_primary_normal)
+ strokeUIColorToken = context.getString(R.string.color_primary_variant_normal)
+ //disable
+ backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled)
+ strokeUIDisabledColorToken = context.getString(R.string.color_outline_disabled)
+ //press
+ backgroundUIPressedColorToken = context.getString(R.string.color_primary_press)
+ strokeUIPressedColorToken = context.getString(R.string.color_primary_variant_press)
+ }
+ changeTextColor(customViewToken)
+ changeBackground(customViewToken)
+ when (iconDirection) {
+ START -> {
+ setIconFontDrawable(
+ startIconFont = iconFont,
+ iconPadding = 8,
+ iconSize = 16,
+ iconColorToken = if (isEnabled) context.getString(R.string.color_txt_primary_specialmap_normal) else context.getString(R.string.color_txt_secondary_disabled)
+ )
+ }
+
+ END -> {
+ setIconFontDrawable(
+ endIconFont = iconFont,
+ iconPadding = 8,
+ iconSize = 16,
+ iconColorToken = if (isEnabled) context.getString(R.string.color_txt_primary_specialmap_normal) else context.getString(R.string.color_txt_secondary_disabled)
+ )
+ }
+ }
+
+ }
+
+ /**
+ * 无筛选状态
+ */
+ fun setInActive() {
+ customViewToken.run {
+ //无筛选状态将描边恢复默认
+ strokeUIWidthToken = ""
+ strokeUIColorToken = ""
+ strokeUIDisabledColorToken = ""
+ strokeUIPressedColorToken = ""
+ //normal
+ textUIColorToken = context.getString(R.string.color_txt_primary_normal)
+ backgroundUIColorToken = context.getString(R.string.color_surface_element_normal)
+ //disable
+ backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled)
+ //press
+ backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press)
+ }
+ changeTextColor(customViewToken)
+ changeBackground(customViewToken)
+ when (iconDirection) {
+ START -> {
+ setIconFontDrawable(
+ startIconFont = iconFont,
+ iconPadding = 8,
+ iconSize = 16,
+ iconColorToken = if (isEnabled) context.getString(R.string.color_txt_secondary_normal) else context.getString(R.string.color_txt_secondary_disabled)
+ )
+ }
+
+ END -> {
+ setIconFontDrawable(
+ endIconFont = iconFont,
+ iconPadding = 8,
+ iconSize = 16,
+ iconColorToken = if (isEnabled) context.getString(R.string.color_txt_secondary_normal) else context.getString(R.string.color_txt_secondary_disabled)
+ )
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/drawable/shape_dialog_hold_to_talk.xml b/VisualNovel/app/src/main/res/drawable/shape_dialog_hold_to_talk.xml
new file mode 100644
index 0000000..17fa0d6
--- /dev/null
+++ b/VisualNovel/app/src/main/res/drawable/shape_dialog_hold_to_talk.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/drawable/shape_oval_talk.xml b/VisualNovel/app/src/main/res/drawable/shape_oval_talk.xml
new file mode 100644
index 0000000..cf4aea2
--- /dev/null
+++ b/VisualNovel/app/src/main/res/drawable/shape_oval_talk.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/layout/activity_actor_chat.xml b/VisualNovel/app/src/main/res/layout/activity_actor_chat.xml
index 3766bfc..6cc7196 100644
--- a/VisualNovel/app/src/main/res/layout/activity_actor_chat.xml
+++ b/VisualNovel/app/src/main/res/layout/activity_actor_chat.xml
@@ -35,7 +35,7 @@
/>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualNovel/app/src/main/res/layout/activity_transaction.xml b/VisualNovel/app/src/main/res/layout/activity_transaction.xml
new file mode 100644
index 0000000..9148574
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/activity_transaction.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualNovel/app/src/main/res/layout/activity_wallet.xml b/VisualNovel/app/src/main/res/layout/activity_wallet.xml
new file mode 100644
index 0000000..5946c5a
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/activity_wallet.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualNovel/app/src/main/res/layout/chat_inputpanel.xml b/VisualNovel/app/src/main/res/layout/chat_inputpanel.xml
index 5e6babd..5fbd689 100644
--- a/VisualNovel/app/src/main/res/layout/chat_inputpanel.xml
+++ b/VisualNovel/app/src/main/res/layout/chat_inputpanel.xml
@@ -15,7 +15,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/layout/dialog_flirting_retrieve.xml b/VisualNovel/app/src/main/res/layout/dialog_flirting_retrieve.xml
new file mode 100644
index 0000000..6907356
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/dialog_flirting_retrieve.xml
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualNovel/app/src/main/res/layout/dialog_hold_to_talk.xml b/VisualNovel/app/src/main/res/layout/dialog_hold_to_talk.xml
new file mode 100644
index 0000000..104cc80
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/dialog_hold_to_talk.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualNovel/app/src/main/res/layout/dialog_recharge.xml b/VisualNovel/app/src/main/res/layout/dialog_recharge.xml
new file mode 100644
index 0000000..06737ac
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/dialog_recharge.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/layout/fragment_recharge.xml b/VisualNovel/app/src/main/res/layout/fragment_recharge.xml
new file mode 100644
index 0000000..9e0369a
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/fragment_recharge.xml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualNovel/app/src/main/res/layout/item_chat_model.xml b/VisualNovel/app/src/main/res/layout/item_chat_model.xml
new file mode 100644
index 0000000..688c100
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/item_chat_model.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/layout/item_flirting_level.xml b/VisualNovel/app/src/main/res/layout/item_flirting_level.xml
new file mode 100644
index 0000000..e28ed85
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/item_flirting_level.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/layout/item_income.xml b/VisualNovel/app/src/main/res/layout/item_income.xml
new file mode 100644
index 0000000..f1f130c
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/item_income.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/layout/item_income_header.xml b/VisualNovel/app/src/main/res/layout/item_income_header.xml
new file mode 100644
index 0000000..929c66a
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/item_income_header.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualNovel/app/src/main/res/layout/item_recharge.xml b/VisualNovel/app/src/main/res/layout/item_recharge.xml
new file mode 100644
index 0000000..f9cd3e6
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/item_recharge.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/layout/layout_simple_recyclerview_refresh.xml b/VisualNovel/app/src/main/res/layout/layout_simple_recyclerview_refresh.xml
new file mode 100644
index 0000000..33e38ba
--- /dev/null
+++ b/VisualNovel/app/src/main/res/layout/layout_simple_recyclerview_refresh.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/book_archive.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/book_archive.webp
new file mode 100644
index 0000000..c967895
Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxhdpi/book_archive.webp differ
diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg.png
new file mode 100644
index 0000000..160bbe2
Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg.png differ
diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg_bottom.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg_bottom.png
new file mode 100644
index 0000000..054aecd
Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg_bottom.png differ
diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg_top.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg_top.png
new file mode 100644
index 0000000..e5b2ac5
Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/crush_level_bg_top.png differ
diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_transaction.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_transaction.png
new file mode 100644
index 0000000..b4a17d6
Binary files /dev/null and b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_transaction.png differ
diff --git a/VisualNovel/app/src/main/res/raw/voice.json b/VisualNovel/app/src/main/res/raw/voice.json
new file mode 100644
index 0000000..17fc4b6
--- /dev/null
+++ b/VisualNovel/app/src/main/res/raw/voice.json
@@ -0,0 +1 @@
+{"v":"5.6.10","fr":24,"ip":0,"op":16,"w":28,"h":28,"nm":"voice","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"形状图层 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[2,14,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,40,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":8,"s":[100,100,100]},{"t":16,"s":[100,40,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-11.875],[0,12.125]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"t":16,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[100]},{"t":16,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":16,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"形状图层 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[26,14,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,40,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":8,"s":[100,100,100]},{"t":16,"s":[100,60,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-11.875],[0,12.125]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"t":16,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[100]},{"t":16,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":16,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"形状图层 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[14,14,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-11.875],[0,12.125]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[40]},{"t":16,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[60]},{"t":16,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":16,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/raw/voice_recording.json b/VisualNovel/app/src/main/res/raw/voice_recording.json
new file mode 100644
index 0000000..38b0ac3
--- /dev/null
+++ b/VisualNovel/app/src/main/res/raw/voice_recording.json
@@ -0,0 +1 @@
+{"v":"5.8.1","fr":24,"ip":0,"op":20,"w":470,"h":80,"nm":"voice","ddd":0,"assets":[{"id":"comp_0","nm":"wave","fr":24,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"line1-1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[223.5,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[45]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[30]},{"t":20,"s":[45]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[55]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[70]},{"t":20,"s":[55]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"line5-1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[199,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[15]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[30]},{"t":20,"s":[15]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[85]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[70]},{"t":20,"s":[85]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"line4-1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[175.5,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[30]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[10]},{"t":20,"s":[30]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[70]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[90]},{"t":20,"s":[70]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"line2-1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[151,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[20]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[40]},{"t":20,"s":[20]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[80]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[60]},{"t":20,"s":[80]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"line5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[127.5,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[10]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[20]},{"t":20,"s":[10]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[90]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[80]},{"t":20,"s":[90]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"line2-1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[103.5,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[20]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"t":20,"s":[20]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[80]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[100]},{"t":20,"s":[80]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"line4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[20]},{"t":20,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[80]},{"t":20,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"line3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[55,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[45]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"t":20,"s":[45]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[55]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[100]},{"t":20,"s":[55]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"line2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[32,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[40]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[25]},{"t":20,"s":[40]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[60]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[75]},{"t":20,"s":[60]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"line1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[7.5,39.875,0],"ix":2,"l":2},"a":{"a":0,"k":[-181,-3.625,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-181,-33.5],[-181,26.25]],"c":false},"ix":2},"nm":"路径 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"描边 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"形状 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[20]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[40]},{"t":20,"s":[20]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[80]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[60]},{"t":20,"s":[80]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"修剪路径 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":20,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"wave","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[355,40,0],"ix":2,"l":2},"a":{"a":0,"k":[115,40,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":230,"h":80,"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"wave","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[115,40,0],"ix":2,"l":2},"a":{"a":0,"k":[115,40,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":230,"h":80,"ip":0,"op":20,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/res/raw/wave.json b/VisualNovel/app/src/main/res/raw/wave.json
new file mode 100644
index 0000000..b1e13ed
--- /dev/null
+++ b/VisualNovel/app/src/main/res/raw/wave.json
@@ -0,0 +1 @@
+{"v":"5.6.9","fr":24,"ip":0,"op":48,"w":124,"h":124,"nm":"合成 2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Up","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[94,65,0],"to":[-15.667,0,0],"ti":[15.667,0,0]},{"t":48,"s":[0,65,0]}],"ix":2},"a":{"a":0,"k":[138,58,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-23,0],[-23,0],[-23,0],[-23,0],[-23,0],[-23,0]],"o":[[0,0],[0,0],[23,0],[23,0],[23,0],[23,0],[23,0],[23,0],[0,0]],"v":[[138,58],[-138,58],[-138,-58],[-92,-48],[-46,-58],[0,-48],[46,-58],[92,-48],[138,-58]],"c":true},"ix":2},"nm":"path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"mergh path","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,0.314,0.671,0.499,1,0.239,0.502,1,1,0.165,0.333,0,0.8,0.5,0.5,1,0.2],"ix":9}},"s":{"a":0,"k":[0,-50],"ix":5},"e":{"a":0,"k":[0,20],"ix":6},"t":1,"nm":"Gradient Fill","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[138,58],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":48,"st":0,"cp":true,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"New Shape 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[-37,67.25,0],"to":[13.805,0,0],"ti":[-19.24,0,0]},{"t":48,"s":[54.8,67.25,0]}],"ix":2},"a":{"a":0,"k":[115,58,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-23,0],[-23,0],[-23,0],[-23,0],[-23,0],[-23,0]],"o":[[0,0],[0,0],[23,0],[23,0],[23,0],[23,0],[23,0],[23,0],[0,0]],"v":[[138,58],[-138,58],[-138,-58],[-92,-48],[-46,-58],[0,-48],[46,-58],[92,-48],[138,-58]],"c":true},"ix":2},"nm":"path2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"merge","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":2,"k":{"a":0,"k":[0,1,1,1,1,0,0,0],"ix":9}},"s":{"a":0,"k":[0,0],"ix":5},"e":{"a":0,"k":[100,0],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":true},{"ty":"fl","c":{"a":0,"k":[0.5,0.5,0.5,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[138,58],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"3","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":48,"st":0,"cp":true,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"G","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[72,64,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[51.03,89.746,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[218.321,205.245],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"path1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,0.455,0,0.204,0.499,0.727,0.057,0.359,1,1,0.114,0.514],"ix":9}},"s":{"a":0,"k":[-76.201,86.642],"ix":5},"e":{"a":0,"k":[89.749,-75.465],"ix":6},"t":1,"nm":"Gradient Fill 2","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-18.063,-2.243],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[143.92,71.442],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":48,"st":0,"cp":true,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"New Shape 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[-37,67.25,0],"to":[13.805,0,0],"ti":[-19.24,0,0]},{"t":48,"s":[54.8,67.25,0]}],"ix":2},"a":{"a":0,"k":[115,58,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-23,0],[-23,0],[-23,0],[-23,0],[-23,0],[-23,0]],"o":[[0,0],[0,0],[23,0],[23,0],[23,0],[23,0],[23,0],[23,0],[0,0]],"v":[[138,58],[-138,58],[-138,-58],[-92,-48],[-46,-58],[0,-48],[46,-58],[92,-48],[138,-58]],"c":true},"ix":2},"nm":"path2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"merge","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,0.455,0,0.204,0.499,0.727,0.057,0.359,1,1,0.114,0.514],"ix":9}},"s":{"a":0,"k":[0,0],"ix":5},"e":{"a":0,"k":[100,0],"ix":6},"t":1,"nm":"Gradient Fill 3","mn":"ADBE Vector Graphic - G-Fill","hd":true},{"ty":"fl","c":{"a":0,"k":[0.5,0.5,0.5,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[138,58],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"3","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":48,"st":0,"cp":true,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/VisualNovel/gradle.properties b/VisualNovel/gradle.properties
index 8d4bd9e..7f04421 100644
--- a/VisualNovel/gradle.properties
+++ b/VisualNovel/gradle.properties
@@ -36,8 +36,15 @@ kapt.include.compile.classpath=false
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false
+# Test flags for improve compile speed
+org.gradle.configureondemand=true
+
+
+
+
+
# business related
KEYSTORE_PWD=visualNoval2025_remax_pw
KEY_ALIAS=visualNoval_alias_remax
KEY_PWD=visualNoval2025_remax_pw
-storeFile=../visual_noval_keystore
\ No newline at end of file
+storeFile=../visual_noval_keystore