feat(mobile): upload queue ui and continuous capture page

Task 9 (P3 plan): queue tab replaces the stub, continuous capture page
enqueues per shot, album detail gains an upload FAB, and the upload
engine lifecycle is now bound to the login session.

- Engine scope: session account drives UploadEngine start/stop; wired
  from app boot, login success, logout, and 401 eviction. Tasks stay in
  the store across logout (accountId hard isolation).
- Queue tab: album x state x time filter bar, task rows with thumbnail,
  state chip, localized error copy, attempt count, per-row progress via
  a broadcast progress hub, state-dependent actions (re-pick retry,
  retryNow, cancel, delete), batch retry bar, 200ms-debounced live
  refresh from drift table updates.
- Capture page: camera preview via injected CaptureSource, one enqueue
  per shutter press, persistent album/category/badges overlay with a
  switch-only-affects-new-shots hint, gallery multi-pick, exit confirm
  while uploads are pending, permission-denied and init-failure views.
- POST_NOTIFICATIONS runtime permission via a small MethodChannel
  (photolive/permissions) + PermissionPlugin.kt: single runtime
  permission need, avoids permission_handler dependency; requested once
  on first enqueue, silent degradation when denied or unavailable.
- Album detail FAB: capture-and-upload / pick-from-gallery sheet.
- l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine
  scope lifecycle, permission gate, queue tab, capture page, FAB entry);
  existing page tests updated to inject session/task-store fakes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 04:47:31 +08:00
parent 42e1c2e34b
commit 955f9b13c1
30 changed files with 4206 additions and 51 deletions
@@ -8,5 +8,16 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine)
// 上传桥接(P3 Task 7):MethodChannel 命令 + EventChannel 事件流。
UploadPlugin.register(applicationContext, flutterEngine)
// 运行时权限桥接(P3 Task 9):POST_NOTIFICATIONS 请求。
PermissionPlugin.register(this, flutterEngine)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
PermissionPlugin.onRequestPermissionsResult(requestCode, grantResults)
}
}
@@ -0,0 +1,78 @@
package com.photolive.photo_live
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/**
* 运行时权限桥接(P3 Task 9):MethodChannel `photolive/permissions`。
*
* 当前仅一个方法:requestPostNotifications——Android 13+(TIRAMISU)的
* POST_NOTIFICATIONS 运时请求(上传前台服务通知可见性;未授权时传输
* 继续,Dart 侧静默降级)。SDK < 33 无需运行时请求,直接回 granted。
*
* 实现说明:App 全部运行时权限需求仅此一项,有意不引入
* permission_handler 依赖(见 Dart 侧 notification_permission.dart 注释)。
* 并发请求单飞:仅首个挂起,后续直接回 denied(Dart 侧本就单次语义)。
*/
object PermissionPlugin {
const val CHANNEL = "photolive/permissions"
private const val METHOD_REQUEST_POST_NOTIFICATIONS = "requestPostNotifications"
private const val REQUEST_CODE = 4101
private var pending: MethodChannel.Result? = null
fun register(activity: MainActivity, engine: FlutterEngine) {
MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
METHOD_REQUEST_POST_NOTIFICATIONS ->
handleRequestPostNotifications(activity, result)
else -> result.notImplemented()
}
}
}
private fun handleRequestPostNotifications(
activity: MainActivity,
result: MethodChannel.Result,
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
result.success("granted")
return
}
val granted = ContextCompat.checkSelfPermission(
activity,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
result.success("granted")
return
}
if (pending != null) {
// 已有请求在途:本 App 侧为单次语义,直接按未授权回包。
result.success("denied")
return
}
pending = result
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_CODE,
)
}
/** MainActivity.onRequestPermissionsResult 转发(回包挂起的首个请求)。 */
fun onRequestPermissionsResult(requestCode: Int, grantResults: IntArray) {
if (requestCode != REQUEST_CODE) return
val result = pending ?: return
pending = null
val granted = grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
result.success(if (granted) "granted" else "denied")
}
}