@@ -84,6 +84,13 @@ private const val BARK_APP_STORE_URL = "https://apps.apple.com/app/bark-custom-n
private const val BARK _DOCS _URL = " https://bark.day.app/#/ "
private const val BARK _GITHUB _URL = " https://github.com/Finb/Bark "
private const val BARK _OFFICIAL _SERVER = " https://api.day.app "
private const val NTFY _OFFICIAL _SERVER = " https://ntfy.sh "
private const val NTFY _ANDROID _URL = " https://play.google.com/store/apps/details?id=io.heckel.ntfy "
private const val NTFY _IOS _URL = " https://apps.apple.com/us/app/ntfy/id1625396347 "
private const val NTFY _FDROID _URL = " https://f-droid.org/en/packages/io.heckel.ntfy/ "
private const val NTFY _DOCS _URL = " https://docs.ntfy.sh/subscribe/phone/ "
private const val NTFY _PUBLISH _DOCS _URL = " https://docs.ntfy.sh/publish/ "
private const val NTFY _GITHUB _URL = " https://github.com/binwiederhier/ntfy "
@Composable
fun SettingsScreen ( onOpenLog : ( ) -> Unit ) {
@@ -97,7 +104,6 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
val ntfyStopFirst by app . settings . ntfyStopOnFirst . collectAsState ( )
val emailStopFirst by app . settings . emailStopOnFirst . collectAsState ( )
val smsStopFirst by app . settings . smsStopOnFirst . collectAsState ( )
val ntfyEnabled by app . settings . ntfyEnabled . collectAsState ( )
val emailEnabled by app . settings . emailForwardingEnabled . collectAsState ( )
val smsFallback by app . settings . smsFallbackEnabled . collectAsState ( )
val iconPrefix by app . settings . iconPrefix . collectAsState ( )
@@ -132,6 +138,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var showBarkHelp by remember { mutableStateOf ( false ) }
var editingBark by remember { mutableStateOf < BarkServer ? > ( null ) }
var showAddNtfy by remember { mutableStateOf ( false ) }
var showNtfyHelp by remember { mutableStateOf ( false ) }
var editingNtfy by remember { mutableStateOf < NtfyServer ? > ( null ) }
var showAddEmail by remember { mutableStateOf ( false ) }
var editingEmail by remember { mutableStateOf < EmailServer ? > ( null ) }
@@ -181,23 +188,34 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// ===== ntfy =====
item {
SectionCard ( " ntfy 转发 " , subtitle = " POST 推送到 ntfy 服务 " ) {
SwitchRow ( title = " 启用 ntfy 转发" , subtitle = if ( ntfyEnabled ) " 已开启 " else " 已关闭 " , checked = ntfyEnabled ) { v -> app . appScope . launch { app . settings . setNtfyEnabled ( v ) } }
if ( ntfyEnabled ) {
SwitchRow ( title = " 发通一条就停 " , subtitle = if ( ntfyStopFirst ) " 容错:首个成功止 " else " 广播:每个都发 " , checked = ntfyStopFirst ) { v -> app . appScope . launch { app . settings . setNtfyStopOnFirst ( v ) } }
SectionCard (
" ntfy 转发 " ,
subtitle = " 勾选的 topic 参与转发;开关切换容错/广播 " ,
headerAction = {
Row ( verticalAlignment = Alignment . CenterVertically ) {
IconButton ( onClick = { showNtfyHelp = true } ) {
Icon ( Icons . Filled . Info , " ntfy 配置帮助 " )
}
CompactStopOnFirstSwitch (
checked = ntfyStopFirst ,
onChange = { v -> app . appScope . launch { app . settings . setNtfyStopOnFirst ( v ) } } ,
)
}
} ,
) {
if ( ntfyServers . isNotEmpty ( ) ) {
Spacer ( Modifier . size ( 8 . dp ) )
Column ( verticalArrangement = Arrangement . spacedBy ( 8 . dp ) ) {
ntfyServers . forEachIndexed { i , s ->
ConfigRow (
title = s . name . ifBlank { s . topic } ,
subtitle = " ${s.server} · topic: ${s.topic} ${if (s.token.isNotBlank()) " · token已填" else ""} " ,
enabled = s . enabled , isFirst = i == 0 , isLast = i == ntfyServers . lastIndex ,
NtfyServerRow (
server = s ,
isFirst = i == 0 ,
isLast = i == ntfyServers . lastIndex ,
onToggle = { v -> app . appScope . launch { app . settings . setNtfyEnabled ( s . id , v ) } } ,
onEdit = { editingNtfy = s } , onDelete = { app . appScope . launch { app . settings . deleteNtfy ( s . id ) } } ,
onEdit = { editingNtfy = s } ,
onDelete = { app . appScope . launch { app . settings . deleteNtfy ( s . id ) } } ,
onMoveUp = { app . appScope . launch { app . settings . moveNtfy ( s . id , true ) } } ,
onMoveDown = { app . appScope . launch { app . settings . moveNtfy ( s . id , false ) } } ,
onTest = { app . appScope . launch { NtfySender . send ( s . server , s . topic , s . token , " a2i测试 " , " ntfy配置测试 " ) } } ,
)
}
}
@@ -205,7 +223,6 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
AddButton { showAddNtfy = true }
}
}
}
// ===== 电邮 =====
item {
@@ -328,6 +345,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// 弹窗
if ( showBarkHelp ) BarkHelpDialog ( onDismiss = { showBarkHelp = false } )
if ( showNtfyHelp ) NtfyHelpDialog ( onDismiss = { showNtfyHelp = false } )
if ( showAddBark ) ServerDialog ( onDismiss = { showAddBark = false } , onConfirm = { n , s , k -> app . appScope . launch { app . settings . addServer ( n , s , k ) } ; showAddBark = false } )
editingBark ?. let { srv -> ServerDialog ( initial = srv , onDismiss = { editingBark = null } , onConfirm = { n , s , k -> app . appScope . launch { app . settings . updateServer ( srv . copy ( name = n , server = s , deviceKey = k ) ) } ; editingBark = null } ) }
if ( showAddNtfy ) NtfyConfigDialog ( onDismiss = { showAddNtfy = false } , onConfirm = { n , sv , t , tk -> app . appScope . launch { app . settings . addNtfy ( NtfyServer ( name = n , server = sv , topic = t , token = tk ) ) } ; showAddNtfy = false } )
@@ -389,12 +407,12 @@ private fun BarkHelpDialog(onDismiss: () -> Unit) {
color = MaterialTheme . colorScheme . onSurfaceVariant ,
)
BarkHelpStep ( " 1 " , " 在 iPhone / iPad 上安装 Bark,首次打开时允许通知权限。 " )
BarkHelpStep ( " 2 " , " 打开 Bark,复制首页显示的推送地址,通常类似 $BARK _OFFICIAL_SERVER/你的Key。 " )
BarkHelpStep ( " 3 " , " 回到 a2i,点击本区域底部的「添加」。服务器地址填 $BARK _OFFICIAL_SERVER,自建服务则填你的自建地址。 " )
BarkHelpStep ( " 4 " , " Device Key 填推送地址最后一段 Key,不要把完整 URL 都填进去。 " )
BarkHelpStep ( " 5 " , " 保存后点服务器行里的发送按钮测试;iPhone 收到「a2i 测试」就说明 Bark 通道已通。 " )
BarkHelpStep ( " 6 " , " 最后回首页开启「通知转发」,并按提示授予系统通知监听权限。 " )
HelpStep ( " 1 " , " 在 iPhone / iPad 上安装 Bark,首次打开时允许通知权限。 " )
HelpStep ( " 2 " , " 打开 Bark,复制首页显示的推送地址,通常类似 $BARK _OFFICIAL_SERVER/你的Key。 " )
HelpStep ( " 3 " , " 回到 a2i,点击本区域底部的「添加」。服务器地址填 $BARK _OFFICIAL_SERVER,自建服务则填你的自建地址。 " )
HelpStep ( " 4 " , " Device Key 填推送地址最后一段 Key,不要把完整 URL 都填进去。 " )
HelpStep ( " 5 " , " 保存后点服务器行里的发送按钮测试;iPhone 收到「a2i 测试」就说明 Bark 通道已通。 " )
HelpStep ( " 6 " , " 最后回首页开启「通知转发」,并按提示授予系统通知监听权限。 " )
Text (
" 常用链接 " ,
@@ -402,9 +420,9 @@ private fun BarkHelpDialog(onDismiss: () -> Unit) {
fontWeight = FontWeight . SemiBold ,
modifier = Modifier . padding ( top = 4 . dp ) ,
)
BarkHelpLink ( " Bark App Store " , BARK _APP _STORE _URL ) { uriHandler . openUri ( it ) }
BarkHelpLink ( " Bark 官方文档 " , BARK _DOCS _URL ) { uriHandler . openUri ( it ) }
BarkHelpLink ( " Bark GitHub " , BARK _GITHUB _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " Bark App Store " , BARK _APP _STORE _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " Bark 官方文档 " , BARK _DOCS _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " Bark GitHub " , BARK _GITHUB _URL ) { uriHandler . openUri ( it ) }
Text (
" 提示:如果使用自建 Bark Server,a2i 里的服务器地址要换成自建地址,但 Device Key 仍然来自 Bark App。 " ,
@@ -419,7 +437,60 @@ private fun BarkHelpDialog(onDismiss: () -> Unit) {
}
@Composable
private fun BarkHelpStep (
private fun NtfyHelpDialog ( onDismiss : ( ) -> Unit ) {
val uriHandler = LocalUriHandler . current
AlertDialog (
onDismissRequest = onDismiss ,
icon = { Icon ( Icons . Filled . Info , null ) } ,
title = { Text ( " ntfy 配置帮助 " ) } ,
text = {
Column (
modifier = Modifier
. heightIn ( max = 440 . dp )
. verticalScroll ( rememberScrollState ( ) ) ,
verticalArrangement = Arrangement . spacedBy ( 10 . dp ) ,
) {
Text (
" ntfy 用来把 a2i 处理后的通知推送到 Android、iOS 或网页版 ntfy。它不需要先建项目,只要接收端和 a2i 使用同一个 server + topic 即可。 " ,
style = MaterialTheme . typography . bodyMedium ,
color = MaterialTheme . colorScheme . onSurfaceVariant ,
)
HelpStep ( " 1 " , " 在接收端安装 ntfy。Android 可用 Google Play / F-Droid / GitHub APK, iPhone / iPad 可用 App Store。 " )
HelpStep ( " 2 " , " 打开 ntfy,添加订阅。服务器通常填 $NTFY _OFFICIAL_SERVER; Topic 建议用一串不容易猜到的随机名字,例如 a2i_xxxxxxxx。 " )
HelpStep ( " 3 " , " 回到 a2i,点击本区域底部的「添加」。服务器和 Topic 要与接收端订阅完全一致。 " )
HelpStep ( " 4 " , " Token 是可选项。只有你在 ntfy 账号或自建服务里把 topic 设成需要鉴权时,才填写 access token。 " )
HelpStep ( " 5 " , " 保存后点 topic 行里的发送按钮测试;接收端收到「a2i 测试」就说明 ntfy 通道已通。 " )
HelpStep ( " 6 " , " 后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个 topic。 " )
Text (
" 常用链接 " ,
style = MaterialTheme . typography . titleSmall ,
fontWeight = FontWeight . SemiBold ,
modifier = Modifier . padding ( top = 4 . dp ) ,
)
HelpLink ( " ntfy Android · Google Play " , NTFY _ANDROID _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " ntfy Android · F-Droid " , NTFY _FDROID _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " ntfy iOS · App Store " , NTFY _IOS _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " ntfy 手机端文档 " , NTFY _DOCS _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " ntfy 发布 API 文档 " , NTFY _PUBLISH _DOCS _URL ) { uriHandler . openUri ( it ) }
HelpLink ( " ntfy GitHub " , NTFY _GITHUB _URL ) { uriHandler . openUri ( it ) }
Text (
" 提示:公共 topic 谁知道名字谁就可能订阅或发送,所以 topic 名不要用手机号、姓名、邮箱等可猜信息。 " ,
style = MaterialTheme . typography . bodySmall ,
color = MaterialTheme . colorScheme . onSurfaceVariant ,
modifier = Modifier . padding ( top = 2 . dp ) ,
)
}
} ,
confirmButton = { TextButton ( onClick = onDismiss ) { Text ( " 知道了 " ) } } ,
)
}
@Composable
private fun HelpStep (
index : String ,
text : String ,
) {
@@ -440,7 +511,7 @@ private fun BarkHelpStep(
}
@Composable
private fun BarkHelpLink (
private fun HelpLink (
title : String ,
url : String ,
onOpen : ( String ) -> Unit ,
@@ -485,7 +556,47 @@ private fun ConfigRow(
}
}
// ===== Bark 服务器 Row + Dialog(保留原有) =====
// ===== ntfy / Bark Row + Dialog =====
@Composable
private fun NtfyServerRow (
server : NtfyServer , isFirst : Boolean , isLast : Boolean ,
onToggle : ( Boolean ) -> Unit , onEdit : ( ) -> Unit , onDelete : ( ) -> Unit ,
onMoveUp : ( ) -> Unit , onMoveDown : ( ) -> Unit ,
) {
val app = A2iApp . instance
var testing by remember { mutableStateOf ( false ) }
var testMsg by remember { mutableStateOf < String ? > ( null ) }
Surface (
modifier = Modifier . fillMaxWidth ( ) , shape = RoundedCornerShape ( 8 . dp ) ,
color = if ( server . enabled ) MaterialTheme . colorScheme . primaryContainer . copy ( alpha = 0.40f ) else MaterialTheme . colorScheme . surfaceVariant . copy ( alpha = 0.36f ) ,
border = BorderStroke ( 1 . dp , if ( server . enabled ) MaterialTheme . colorScheme . primary . copy ( alpha = 0.30f ) else MaterialTheme . colorScheme . outlineVariant ) ,
) {
Column ( Modifier . padding ( start = 4 . dp , end = 4 . dp , top = 2 . dp , bottom = 6 . dp ) ) {
Row ( verticalAlignment = Alignment . CenterVertically ) {
Checkbox ( checked = server . enabled , onCheckedChange = onToggle )
Row ( Modifier . weight ( 1f ) , verticalAlignment = Alignment . CenterVertically ) {
Text ( server . name . ifBlank { server . topic . ifBlank { " 未命名 " } } , style = MaterialTheme . typography . bodyLarge , fontWeight = FontWeight . Medium , maxLines = 1 , overflow = TextOverflow . Ellipsis , modifier = Modifier . weight ( 1f , fill = false ) )
if ( ! server . enabled ) { Spacer ( Modifier . width ( 8 . dp ) ) ; StatusBadge ( " 停用 " , MaterialTheme . colorScheme . onSurfaceVariant ) }
}
IconButton ( onClick = { if ( ! testing ) app . appScope . launch { testing = true ; testMsg = testNtfy ( server ) ; testing = false } } , enabled = ! testing ) {
if ( testing ) CircularProgressIndicator ( Modifier . size ( 18 . dp ) , strokeWidth = 2 . dp ) else Icon ( Icons . AutoMirrored . Filled . Send , " 测试 " )
}
IconButton ( onClick = onEdit ) { Icon ( Icons . Filled . Edit , " 编辑 " ) }
IconButton ( onClick = onDelete ) { Icon ( Icons . Filled . Delete , " 删除 " ) }
}
Row ( verticalAlignment = Alignment . CenterVertically , modifier = Modifier . padding ( top = 2 . dp ) ) {
Column ( Modifier . weight ( 1f ) . padding ( start = 36 . dp ) ) {
Text ( server . server , style = MaterialTheme . typography . bodySmall , color = MaterialTheme . colorScheme . onSurfaceVariant , maxLines = 2 , overflow = TextOverflow . Ellipsis )
Text ( " topic: " + server . topic . ifBlank { " 未填 " } + if ( server . token . isNotBlank ( ) ) " · token已填 " else " " , style = MaterialTheme . typography . labelSmall , color = MaterialTheme . colorScheme . onSurfaceVariant , maxLines = 1 , overflow = TextOverflow . Ellipsis )
}
IconButton ( onClick = onMoveUp , enabled = !is First ) { Icon ( Icons . Filled . KeyboardArrowUp , " 上移 " ) }
IconButton ( onClick = onMoveDown , enabled = !is Last ) { Icon ( Icons . Filled . KeyboardArrowDown , " 下移 " ) }
}
testMsg ?. let { Text ( it , style = MaterialTheme . typography . bodySmall , color = if ( it . startsWith ( " 已发送 " ) ) MaterialTheme . colorScheme . tertiary else MaterialTheme . colorScheme . error , modifier = Modifier . padding ( start = 48 . dp , top = 2 . dp ) ) }
}
}
}
@Composable
private fun ServerRow (
server : BarkServer , isFirst : Boolean , isLast : Boolean ,
@@ -543,7 +654,7 @@ private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onC
@Composable
private fun NtfyConfigDialog ( initial : NtfyServer ? = null , onDismiss : ( ) -> Unit , onConfirm : ( String , String , String , String ) -> Unit ) {
var name by remember { mutableStateOf ( initial ?. name ?: " " ) }
var server by remember { mutableStateOf ( initial ?. server ?: " https://ntfy.sh " ) }
var server by remember { mutableStateOf ( initial ?. server ?: NTFY _OFFICIAL _SERVER ) }
var topic by remember { mutableStateOf ( initial ?. topic ?: " " ) }
var token by remember { mutableStateOf ( initial ?. token ?: " " ) }
ConfigDialogSkeleton ( title = if ( initial == null ) " 添加 ntfy " else " 编辑 ntfy " , onDismiss = onDismiss , onConfirm = { onConfirm ( name . trim ( ) , server . trim ( ) . removeSuffix ( " / " ) , topic . trim ( ) , token . trim ( ) ) } ) {
@@ -613,3 +724,9 @@ private suspend fun testPush(app: A2iApp, server: BarkServer): String {
val r = BarkClient ( server ) . push ( msg )
return if ( r . isSuccess ) " 已发送,请查看 iOS 端 " else " 发送失败: ${r.exceptionOrNull()?.message} "
}
private suspend fun testNtfy ( server : NtfyServer ) : String {
if ( server . topic . isBlank ( ) ) return " 未填写 topic "
val r = NtfySender . send ( server . server , server . topic , server . token , " a2i 测试 " , " 如果你在 ntfy 看到这条推送,说明配置成功 " )
return if ( r . isSuccess ) " 已发送,请查看 ntfy 接收端 " else " 发送失败: ${r.exceptionOrNull()?.message} "
}