feat: add flutter mobile console and refine login ui #2
@@ -5,6 +5,7 @@
|
||||
**/*.suo
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
.codex-temp/
|
||||
build.log
|
||||
webapi-build.log
|
||||
webapi-build-no-restore.log
|
||||
|
||||
@@ -12,7 +12,7 @@ const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const loading = ref(false);
|
||||
const { backendUnavailable, backendMessage } = useBackendStatus();
|
||||
const { themeMode, resolvedTheme } = useUiPreferences();
|
||||
const { themeMode } = useUiPreferences();
|
||||
|
||||
const form = reactive({
|
||||
username: "admin",
|
||||
@@ -52,39 +52,11 @@ async function handleLogin() {
|
||||
|
||||
<template>
|
||||
<div class="login-screen">
|
||||
<section class="login-hero">
|
||||
<div class="login-hero__eyebrow">Live Recorder</div>
|
||||
<h1 class="login-hero__title">把直播录制做成可长期维护的专业控制台。</h1>
|
||||
<p class="login-hero__subtitle">
|
||||
统一管理直播间、自动开录、恢复流程、系统日志、事件脚本和日报回顾,让录制系统像真正的运维平台一样稳定工作。
|
||||
</p>
|
||||
|
||||
<div class="login-hero__grid">
|
||||
<article class="login-hero__tile">
|
||||
<strong>实时监控</strong>
|
||||
<span>直播间、自动开录决策、会话与分片状态集中可见。</span>
|
||||
</article>
|
||||
<article class="login-hero__tile">
|
||||
<strong>恢复能力</strong>
|
||||
<span>中断转码、暂停录制、重启恢复都有统一入口。</span>
|
||||
</article>
|
||||
<article class="login-hero__tile">
|
||||
<strong>自动化</strong>
|
||||
<span>邮件、Webhook、事件脚本与自定义日志全部贯通。</span>
|
||||
</article>
|
||||
<article class="login-hero__tile">
|
||||
<strong>回顾分析</strong>
|
||||
<span>时间轴和日报帮助我们快速复盘每一场直播。</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="login-panel surface-card">
|
||||
<div class="login-panel__header">
|
||||
<div>
|
||||
<div class="login-panel__kicker">Sign In</div>
|
||||
<h2 class="login-panel__title">进入控制台</h2>
|
||||
<p class="login-panel__subtitle">默认账户为 <span class="monospace">admin / Admin@123</span></p>
|
||||
<div class="login-panel__eyebrow">Live Recorder</div>
|
||||
<h1 class="login-panel__title">登录</h1>
|
||||
</div>
|
||||
|
||||
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
|
||||
@@ -123,14 +95,9 @@ async function handleLogin() {
|
||||
</el-form-item>
|
||||
|
||||
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
|
||||
登录控制台
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="login-panel__footer">
|
||||
<span>当前主题:{{ resolvedTheme === "dark" ? "深色" : "浅色" }}</span>
|
||||
<span>双端适配:Web / Mobile</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -139,78 +106,12 @@ async function handleLogin() {
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 440px);
|
||||
gap: 48px;
|
||||
padding: 48px 56px;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.login-hero__eyebrow,
|
||||
.login-panel__kicker {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-hero__title {
|
||||
margin: 0;
|
||||
max-width: 11ch;
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(44px, 5vw, 72px);
|
||||
font-weight: 780;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.94;
|
||||
}
|
||||
|
||||
.login-hero__subtitle {
|
||||
max-width: 60ch;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.login-hero__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.login-hero__tile {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .login-hero__tile {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.login-hero__tile strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-hero__tile span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
align-self: center;
|
||||
width: min(100%, 420px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -222,6 +123,14 @@ async function handleLogin() {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-panel__eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-panel__title {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-primary);
|
||||
@@ -230,13 +139,6 @@ async function handleLogin() {
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.login-panel__subtitle {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-panel__theme-select {
|
||||
width: 122px;
|
||||
}
|
||||
@@ -250,59 +152,16 @@ async function handleLogin() {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-panel__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.login-screen {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
padding: 28px 20px;
|
||||
}
|
||||
|
||||
.login-hero__grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.login-screen {
|
||||
padding: 18px 14px 24px;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.login-hero__title {
|
||||
max-width: none;
|
||||
font-size: clamp(34px, 12vw, 48px);
|
||||
}
|
||||
|
||||
.login-hero__subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-panel__header,
|
||||
.login-panel__footer {
|
||||
.login-panel__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.dart_tool/
|
||||
.tmp/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub/
|
||||
build/
|
||||
coverage/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
@@ -0,0 +1,24 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
- platform: android
|
||||
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
|
||||
unmanaged_files:
|
||||
- "lib/main.dart"
|
||||
- "android/app/src/main/kotlin/com/liverecorder/mobile/MainActivity.kt"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
avoid_print: false
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.liverecorder.mobile"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.liverecorder.mobile"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="28" />
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="LiveRecorder"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package io.flutter.plugins;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.Log;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
/**
|
||||
* Generated file. Do not edit.
|
||||
* This file is generated by the Flutter tool based on the
|
||||
* plugins that support the Android platform.
|
||||
*/
|
||||
@Keep
|
||||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.liverecorder.mobile
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
allprojects {
|
||||
buildscript {
|
||||
repositories {
|
||||
maven("https://maven.aliyun.com/repository/public")
|
||||
maven("https://maven.aliyun.com/repository/google")
|
||||
maven("https://maven.aliyun.com/repository/central")
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven("https://maven.aliyun.com/repository/public")
|
||||
maven("https://maven.aliyun.com/repository/google")
|
||||
maven("https://maven.aliyun.com/repository/central")
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
Vendored
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,29 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
||||
maven("https://maven.aliyun.com/repository/google")
|
||||
maven("https://maven.aliyun.com/repository/central")
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/app/app_theme.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/login_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/mobile_shell_page.dart';
|
||||
|
||||
class LiveRecorderBootstrap extends StatefulWidget {
|
||||
const LiveRecorderBootstrap({
|
||||
super.key,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
final ApiConfig config;
|
||||
|
||||
@override
|
||||
State<LiveRecorderBootstrap> createState() => _LiveRecorderBootstrapState();
|
||||
}
|
||||
|
||||
class _LiveRecorderBootstrapState extends State<LiveRecorderBootstrap> {
|
||||
late final AppBootstrapController<AppDependencies> _bootstrapController =
|
||||
AppBootstrapController<AppDependencies>(
|
||||
config: widget.config,
|
||||
configStorage: AppConfigStorage(),
|
||||
dependenciesFactory: (String baseUrl) => AppDependencies.create(baseUrl: baseUrl),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapController.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bootstrapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _bootstrapController,
|
||||
builder: (BuildContext context, _) {
|
||||
return AppScope(
|
||||
backendConfig: _bootstrapController,
|
||||
dependencies: _bootstrapController.dependencies,
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'LiveRecorder',
|
||||
theme: buildLiveRecorderTheme(),
|
||||
home: _BootstrapHome(
|
||||
bootstrapController: _bootstrapController,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BootstrapHome extends StatelessWidget {
|
||||
const _BootstrapHome({
|
||||
required this.bootstrapController,
|
||||
});
|
||||
|
||||
final AppBootstrapController<AppDependencies> bootstrapController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bootstrapController.isInitializing) {
|
||||
return const _LoadingSplashPage();
|
||||
}
|
||||
|
||||
if (bootstrapController.initializationErrorMessage != null) {
|
||||
return _BootstrapErrorPage(
|
||||
message: bootstrapController.initializationErrorMessage!,
|
||||
onRetry: bootstrapController.initialize,
|
||||
);
|
||||
}
|
||||
|
||||
if (!bootstrapController.hasConfiguredBackend) {
|
||||
return BackendSetupPage(
|
||||
bootstrapController: bootstrapController,
|
||||
);
|
||||
}
|
||||
|
||||
final dependencies = bootstrapController.dependencies;
|
||||
if (dependencies == null) {
|
||||
return _BootstrapErrorPage(
|
||||
message: '后端配置未能正确加载,请重试',
|
||||
onRetry: bootstrapController.initialize,
|
||||
);
|
||||
}
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: dependencies.sessionController,
|
||||
builder: (BuildContext context, _) {
|
||||
if (dependencies.sessionController.isRestoring) {
|
||||
return const _LoadingSplashPage();
|
||||
}
|
||||
|
||||
if (dependencies.sessionController.isLoggedIn) {
|
||||
return MobileShellPage(
|
||||
dependencies: dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
return LoginPage(
|
||||
sessionController: dependencies.sessionController,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingSplashPage extends StatelessWidget {
|
||||
const _LoadingSplashPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BootstrapErrorPage extends StatelessWidget {
|
||||
const _BootstrapErrorPage({
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: AppErrorCard(
|
||||
message: message,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
|
||||
|
||||
typedef AppDependenciesFactory<T extends AppDependencyBundle> = T Function(String baseUrl);
|
||||
|
||||
abstract interface class BackendConfigHandle extends Listenable {
|
||||
String get seedBaseUrl;
|
||||
String? get backendBaseUrl;
|
||||
bool get hasConfiguredBackend;
|
||||
bool get isInitializing;
|
||||
String? get initializationErrorMessage;
|
||||
|
||||
Future<void> initialize();
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue);
|
||||
Future<bool> updateBackendBaseUrl(String rawValue);
|
||||
}
|
||||
|
||||
class AppBootstrapController<T extends AppDependencyBundle> extends ChangeNotifier
|
||||
implements BackendConfigHandle {
|
||||
AppBootstrapController({
|
||||
required ApiConfig config,
|
||||
required BackendConfigStore configStorage,
|
||||
required AppDependenciesFactory<T> dependenciesFactory,
|
||||
}) : _config = config,
|
||||
_configStorage = configStorage,
|
||||
_dependenciesFactory = dependenciesFactory;
|
||||
|
||||
final ApiConfig _config;
|
||||
final BackendConfigStore _configStorage;
|
||||
final AppDependenciesFactory<T> _dependenciesFactory;
|
||||
|
||||
T? _dependencies;
|
||||
String? _backendBaseUrl;
|
||||
bool _isInitializing = true;
|
||||
String? _initializationErrorMessage;
|
||||
|
||||
T? get dependencies => _dependencies;
|
||||
|
||||
@override
|
||||
String get seedBaseUrl => _config.seedBaseUrl;
|
||||
|
||||
@override
|
||||
String? get backendBaseUrl => _backendBaseUrl;
|
||||
|
||||
@override
|
||||
bool get hasConfiguredBackend => _backendBaseUrl != null && _backendBaseUrl!.isNotEmpty;
|
||||
|
||||
@override
|
||||
bool get isInitializing => _isInitializing;
|
||||
|
||||
@override
|
||||
String? get initializationErrorMessage => _initializationErrorMessage;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
_setInitializing(true);
|
||||
try {
|
||||
final storedBaseUrl = await _configStorage.readBackendBaseUrl();
|
||||
if (storedBaseUrl == null || storedBaseUrl.trim().isEmpty) {
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(storedBaseUrl);
|
||||
_backendBaseUrl = normalizedBaseUrl;
|
||||
await _rebuildDependencies(normalizedBaseUrl);
|
||||
} on FormatException {
|
||||
await _configStorage.clear();
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
} catch (_) {
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
_initializationErrorMessage = '读取后端地址失败,请重试';
|
||||
} finally {
|
||||
_setInitializing(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
|
||||
await _persistAndApplyBackendBaseUrl(
|
||||
normalizedBaseUrl,
|
||||
clearExistingSession: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updateBackendBaseUrl(String rawValue) async {
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
|
||||
if (normalizedBaseUrl == _backendBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _persistAndApplyBackendBaseUrl(
|
||||
normalizedBaseUrl,
|
||||
clearExistingSession: true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _persistAndApplyBackendBaseUrl(
|
||||
String normalizedBaseUrl, {
|
||||
required bool clearExistingSession,
|
||||
}) async {
|
||||
final previousDependencies = _dependencies;
|
||||
_setInitializing(true);
|
||||
|
||||
try {
|
||||
await _configStorage.writeBackendBaseUrl(normalizedBaseUrl);
|
||||
if (clearExistingSession) {
|
||||
await previousDependencies?.sessionController.clearLocalSession();
|
||||
}
|
||||
|
||||
_backendBaseUrl = normalizedBaseUrl;
|
||||
await _rebuildDependencies(normalizedBaseUrl);
|
||||
} catch (error) {
|
||||
if (!identical(previousDependencies, _dependencies)) {
|
||||
_dependencies?.dispose();
|
||||
_dependencies = previousDependencies;
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
_setInitializing(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rebuildDependencies(String baseUrl) async {
|
||||
final nextDependencies = _dependenciesFactory(baseUrl);
|
||||
final previousDependencies = _dependencies;
|
||||
_dependencies = nextDependencies;
|
||||
|
||||
try {
|
||||
await nextDependencies.sessionController.restore();
|
||||
previousDependencies?.dispose();
|
||||
} catch (_) {
|
||||
nextDependencies.dispose();
|
||||
_dependencies = previousDependencies;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _setInitializing(bool value) {
|
||||
_isInitializing = value;
|
||||
if (value) {
|
||||
_initializationErrorMessage = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _disposeDependencies() {
|
||||
final dependencies = _dependencies;
|
||||
_dependencies = null;
|
||||
dependencies?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeDependencies();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/logs_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
|
||||
|
||||
abstract interface class AppDependencyBundle {
|
||||
SessionControllerHandle get sessionController;
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class AppDependencies implements AppDependencyBundle {
|
||||
AppDependencies._({
|
||||
required this.backendBaseUrl,
|
||||
required this.apiClient,
|
||||
required this.sessionStorage,
|
||||
required this.authRepository,
|
||||
required this.liveRoomsRepository,
|
||||
required this.recordingsRepository,
|
||||
required this.recoveryRepository,
|
||||
required this.settingsRepository,
|
||||
required this.logsRepository,
|
||||
required this.mediaRepository,
|
||||
required this.sessionController,
|
||||
});
|
||||
|
||||
factory AppDependencies.create({
|
||||
required String baseUrl,
|
||||
}) {
|
||||
late AppSessionController sessionController;
|
||||
final sessionStorage = SessionStorage();
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: baseUrl,
|
||||
tokenProvider: () => sessionController.token,
|
||||
onUnauthorized: () async => sessionController.handleUnauthorized(),
|
||||
);
|
||||
final authRepository = AuthRepository(apiClient);
|
||||
sessionController = AppSessionController(
|
||||
authRepository: authRepository,
|
||||
sessionStorage: sessionStorage,
|
||||
);
|
||||
|
||||
return AppDependencies._(
|
||||
backendBaseUrl: baseUrl,
|
||||
apiClient: apiClient,
|
||||
sessionStorage: sessionStorage,
|
||||
authRepository: authRepository,
|
||||
liveRoomsRepository: LiveRoomsRepository(apiClient),
|
||||
recordingsRepository: RecordingsRepository(apiClient),
|
||||
recoveryRepository: RecoveryRepository(apiClient),
|
||||
settingsRepository: SettingsRepository(apiClient),
|
||||
logsRepository: LogsRepository(apiClient),
|
||||
mediaRepository: MediaRepository(apiClient),
|
||||
sessionController: sessionController,
|
||||
);
|
||||
}
|
||||
|
||||
final String backendBaseUrl;
|
||||
final ApiClient apiClient;
|
||||
final SessionStorage sessionStorage;
|
||||
final AuthRepository authRepository;
|
||||
final LiveRoomsRepository liveRoomsRepository;
|
||||
final RecordingsRepository recordingsRepository;
|
||||
final RecoveryRepository recoveryRepository;
|
||||
final SettingsRepository settingsRepository;
|
||||
final LogsRepository logsRepository;
|
||||
final MediaRepository mediaRepository;
|
||||
@override
|
||||
final AppSessionController sessionController;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
apiClient.dispose();
|
||||
sessionController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app_bootstrap_controller.dart';
|
||||
import 'app_dependencies.dart';
|
||||
|
||||
class AppScope extends InheritedWidget {
|
||||
const AppScope({
|
||||
super.key,
|
||||
required this.backendConfig,
|
||||
required this.dependencies,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final BackendConfigHandle backendConfig;
|
||||
final AppDependencies? dependencies;
|
||||
|
||||
static AppDependencies of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope is not available in this context.');
|
||||
final dependencies = scope!.dependencies;
|
||||
assert(dependencies != null, 'AppDependencies are not available in this context.');
|
||||
return dependencies!;
|
||||
}
|
||||
|
||||
static BackendConfigHandle backendConfigOf(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope is not available in this context.');
|
||||
return scope!.backendConfig;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(AppScope oldWidget) {
|
||||
return dependencies != oldWidget.dependencies || backendConfig != oldWidget.backendConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ThemeData buildLiveRecorderTheme() {
|
||||
const seed = Color(0xFF2563EB);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
primary: seed,
|
||||
surface: Colors.white,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF6F8FB),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
side: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFF0F172A),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 72,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle?>(
|
||||
(Set<WidgetState> states) {
|
||||
final color = states.contains(WidgetState.selected)
|
||||
? const Color(0xFF2563EB)
|
||||
: const Color(0xFF64748B);
|
||||
return TextStyle(
|
||||
color: color,
|
||||
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500,
|
||||
);
|
||||
},
|
||||
),
|
||||
indicatorColor: const Color(0xFFE0ECFF),
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
hintStyle: const TextStyle(color: Color(0xFF64748B)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFF2563EB), width: 1.4),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
selectedColor: const Color(0xFFE0ECFF),
|
||||
side: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
labelStyle: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Color(0xFFE2E8F0),
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class ApiConfig {
|
||||
const ApiConfig({
|
||||
this.seedBaseUrl = '',
|
||||
});
|
||||
|
||||
final String seedBaseUrl;
|
||||
|
||||
static ApiConfig fromEnvironment() {
|
||||
const rawValue = String.fromEnvironment('LIVE_RECORDER_API_BASE_URL');
|
||||
return ApiConfig(seedBaseUrl: rawValue.trim());
|
||||
}
|
||||
|
||||
bool get hasSeedBaseUrl => seedBaseUrl.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
typedef TokenProvider = String? Function();
|
||||
typedef UnauthorizedCallback = Future<void> Function();
|
||||
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
required String baseUrl,
|
||||
required TokenProvider tokenProvider,
|
||||
required UnauthorizedCallback onUnauthorized,
|
||||
http.Client? client,
|
||||
}) : _baseUri = Uri.parse(baseUrl),
|
||||
_tokenProvider = tokenProvider,
|
||||
_onUnauthorized = onUnauthorized,
|
||||
_client = client ?? http.Client();
|
||||
|
||||
final Uri _baseUri;
|
||||
final TokenProvider _tokenProvider;
|
||||
final UnauthorizedCallback _onUnauthorized;
|
||||
final http.Client _client;
|
||||
|
||||
Uri buildUri(
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return Uri.parse(path);
|
||||
}
|
||||
|
||||
final normalizedPath = path.startsWith('/') ? path.substring(1) : path;
|
||||
final basePath = _baseUri.path == '/' ? '' : _baseUri.path.replaceAll(RegExp(r'/+$'), '');
|
||||
final resolvedPath = basePath.isEmpty ? '/$normalizedPath' : '$basePath/$normalizedPath';
|
||||
final resolved = _baseUri.replace(path: resolvedPath);
|
||||
if (queryParameters == null || queryParameters.isEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return resolved.replace(
|
||||
queryParameters: <String, String>{
|
||||
...resolved.queryParameters,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> getJson(
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'GET',
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> postJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'POST',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> putJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'PUT',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> deleteJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'DELETE',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _sendJsonRequest(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) async {
|
||||
final uri = buildUri(path, queryParameters: queryParameters);
|
||||
final request = http.Request(method, uri);
|
||||
request.headers.addAll(_buildHeaders());
|
||||
|
||||
if (body != null) {
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
|
||||
http.StreamedResponse streamedResponse;
|
||||
try {
|
||||
streamedResponse = await _client.send(request);
|
||||
} on Exception catch (error) {
|
||||
throw ApiException(message: '无法连接后端服务', detail: error.toString());
|
||||
}
|
||||
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
return _decodeJsonResponse(response);
|
||||
}
|
||||
|
||||
Future<void> postEmpty(
|
||||
String path, {
|
||||
Object? body,
|
||||
}) async {
|
||||
await postJson(path, body: body);
|
||||
}
|
||||
|
||||
Map<String, String> _buildHeaders() {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
final token = _tokenProvider()?.trim();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
dynamic _decodeJsonResponse(http.Response response) {
|
||||
if (response.statusCode == 401) {
|
||||
_onUnauthorized();
|
||||
}
|
||||
|
||||
final bodyText = utf8.decode(response.bodyBytes);
|
||||
final jsonBody = bodyText.trim().isEmpty ? null : jsonDecode(bodyText);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return jsonBody;
|
||||
}
|
||||
|
||||
throw ApiException(
|
||||
message: _resolveErrorMessage(response.statusCode, jsonBody),
|
||||
statusCode: response.statusCode,
|
||||
detail: jsonBody is Map<String, dynamic>
|
||||
? (jsonBody['detail'] ?? jsonBody['error'])?.toString()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveErrorMessage(int statusCode, dynamic body) {
|
||||
if (body is Map<String, dynamic>) {
|
||||
final candidate = <dynamic>[
|
||||
body['message'],
|
||||
body['title'],
|
||||
body['detail'],
|
||||
body['error'],
|
||||
].firstWhere(
|
||||
(value) => value is String && value.trim().isNotEmpty,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (candidate is String) {
|
||||
return candidate;
|
||||
}
|
||||
} else if (body is String && body.trim().isNotEmpty) {
|
||||
return body;
|
||||
}
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return '请求参数有误,请检查后重试';
|
||||
case 401:
|
||||
return '登录状态已失效,请重新登录';
|
||||
case 403:
|
||||
return '当前没有权限执行该操作';
|
||||
case 404:
|
||||
return '请求的接口不存在';
|
||||
case 409:
|
||||
return '请求发生冲突,请刷新后重试';
|
||||
case 422:
|
||||
return '提交的数据格式不正确,请检查后重试';
|
||||
case 500:
|
||||
return '后端服务发生内部错误';
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return '后端服务暂时不可用,请稍后重试';
|
||||
default:
|
||||
return '请求失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final String? detail;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer('ApiException(message: $message');
|
||||
if (statusCode != null) {
|
||||
buffer.write(', statusCode: $statusCode');
|
||||
}
|
||||
if (detail != null && detail!.isNotEmpty) {
|
||||
buffer.write(', detail: $detail');
|
||||
}
|
||||
buffer.write(')');
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
abstract interface class BackendConfigStore {
|
||||
Future<String?> readBackendBaseUrl();
|
||||
Future<void> writeBackendBaseUrl(String baseUrl);
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class AppConfigStorage implements BackendConfigStore {
|
||||
@override
|
||||
Future<String?> readBackendBaseUrl() async {
|
||||
final file = await _configFile();
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final payload = jsonDecode(content);
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final value = payload['backendBaseUrl']?.toString().trim();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeBackendBaseUrl(String baseUrl) async {
|
||||
final file = await _configFile();
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsString(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'backendBaseUrl': baseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final file = await _configFile();
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _configFile() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
return File('${directory.path}${Platform.pathSeparator}live_recorder_app_config.json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class SessionStorage {
|
||||
Future<Map<String, dynamic>?> read() async {
|
||||
final file = await _sessionFile();
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jsonDecode(content) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> write(Map<String, dynamic> payload) async {
|
||||
final file = await _sessionFile();
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsString(jsonEncode(payload));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final file = await _sessionFile();
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _sessionFile() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
return File('${directory.path}${Platform.pathSeparator}live_recorder_session.json');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
|
||||
class PollingController {
|
||||
PollingController({
|
||||
required Duration interval,
|
||||
required Future<void> Function() onTick,
|
||||
}) : _interval = interval,
|
||||
_onTick = onTick;
|
||||
|
||||
final Duration _interval;
|
||||
final Future<void> Function() _onTick;
|
||||
|
||||
Timer? _timer;
|
||||
bool _active = false;
|
||||
bool _busy = false;
|
||||
|
||||
void setActive(bool active) {
|
||||
if (_active == active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_active = active;
|
||||
if (_active) {
|
||||
_schedule();
|
||||
triggerNow();
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void triggerNow() {
|
||||
if (!_active || _busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
_tick();
|
||||
}
|
||||
|
||||
Future<void> _tick() async {
|
||||
_busy = true;
|
||||
try {
|
||||
await _onTick();
|
||||
} finally {
|
||||
_busy = false;
|
||||
_schedule();
|
||||
}
|
||||
}
|
||||
|
||||
void _schedule() {
|
||||
_timer?.cancel();
|
||||
if (!_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_timer = Timer(_interval, () {
|
||||
if (_active && !_busy) {
|
||||
_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
String normalizeBackendBaseUrl(String rawValue) {
|
||||
final trimmed = rawValue.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('请输入后端地址');
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null ||
|
||||
!uri.hasScheme ||
|
||||
(uri.scheme != 'http' && uri.scheme != 'https') ||
|
||||
uri.host.isEmpty) {
|
||||
throw const FormatException('请输入以 http:// 或 https:// 开头的完整地址');
|
||||
}
|
||||
|
||||
if (uri.query.isNotEmpty || uri.fragment.isNotEmpty) {
|
||||
throw const FormatException('后端地址不能包含查询参数或片段');
|
||||
}
|
||||
|
||||
var normalizedPath = uri.path.replaceAll(RegExp(r'/+$'), '');
|
||||
if (normalizedPath == '/') {
|
||||
normalizedPath = '';
|
||||
}
|
||||
|
||||
return uri.replace(path: normalizedPath).toString();
|
||||
}
|
||||
|
||||
String? validateBackendBaseUrl(String rawValue) {
|
||||
try {
|
||||
normalizeBackendBaseUrl(rawValue);
|
||||
return null;
|
||||
} on FormatException catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
final DateFormat _timeFormat = DateFormat('HH:mm');
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
|
||||
String formatDateTime(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _dateTimeFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatTime(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _timeFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatDateOnly(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _dateFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatDurationSeconds(num? seconds) {
|
||||
if (seconds == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final totalSeconds = seconds.round();
|
||||
final hours = totalSeconds ~/ 3600;
|
||||
final minutes = (totalSeconds % 3600) ~/ 60;
|
||||
final remainingSeconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return '${minutes}m ${remainingSeconds}s';
|
||||
}
|
||||
return '${remainingSeconds}s';
|
||||
}
|
||||
|
||||
String formatBytes(num? bytes) {
|
||||
if (bytes == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const units = <String>['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var value = bytes.toDouble();
|
||||
var index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
final fractionDigits = index == 0 ? 0 : index == 1 ? 1 : 2;
|
||||
return '${value.toStringAsFixed(fractionDigits)} ${units[index]}';
|
||||
}
|
||||
|
||||
String valueOrDash(Object? value) {
|
||||
if (value == null) {
|
||||
return '--';
|
||||
}
|
||||
final text = value.toString().trim();
|
||||
return text.isEmpty ? '--' : text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
Uri? resolveLiveRoomWatchUri(LiveRoom room) {
|
||||
for (final String candidate in <String>[
|
||||
room.normalizedUrl,
|
||||
room.sourceUrl,
|
||||
room.originalLiveRoomUrl,
|
||||
]) {
|
||||
final uri = _parseHttpUri(candidate);
|
||||
if (uri != null) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool hasLiveRoomWatchSource(LiveRoom room) {
|
||||
return room.normalizedUrl.trim().isNotEmpty ||
|
||||
room.sourceUrl.trim().isNotEmpty ||
|
||||
room.originalLiveRoomUrl.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
int compareMonitorRooms(LiveRoom a, LiveRoom b) {
|
||||
final liveA = a.availabilityStatus == 2 ? 1 : 0;
|
||||
final liveB = b.availabilityStatus == 2 ? 1 : 0;
|
||||
if (liveA != liveB) {
|
||||
return liveB.compareTo(liveA);
|
||||
}
|
||||
|
||||
final recordingA = a.currentRecordingState == 2 ? 1 : 0;
|
||||
final recordingB = b.currentRecordingState == 2 ? 1 : 0;
|
||||
if (recordingA != recordingB) {
|
||||
return recordingB.compareTo(recordingA);
|
||||
}
|
||||
|
||||
final priorityA = (a.isPinned || a.isPriority) ? 1 : 0;
|
||||
final priorityB = (b.isPinned || b.isPriority) ? 1 : 0;
|
||||
if (priorityA != priorityB) {
|
||||
return priorityB.compareTo(priorityA);
|
||||
}
|
||||
|
||||
return b.updatedAt.compareTo(a.updatedAt);
|
||||
}
|
||||
|
||||
Uri? _parseHttpUri(String? rawValue) {
|
||||
final trimmed = rawValue?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
String? deriveRelativeMediaPath({
|
||||
required String? outputRoot,
|
||||
required String? outputFilePath,
|
||||
}) {
|
||||
final rawPath = outputFilePath?.trim();
|
||||
if (rawPath == null || rawPath.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalizedPath = rawPath.replaceAll('\\', '/');
|
||||
if (_containsUnsafeTraversal(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final root = outputRoot?.trim();
|
||||
if (root == null || root.isEmpty) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
final normalizedRoot = root.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), '');
|
||||
final normalizedPathLower = normalizedPath.toLowerCase();
|
||||
final normalizedRootLower = normalizedRoot.toLowerCase();
|
||||
|
||||
if (normalizedPathLower == normalizedRootLower) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedPathLower.startsWith('$normalizedRootLower/')) {
|
||||
final relative = normalizedPath.substring(normalizedRoot.length + 1);
|
||||
return _containsUnsafeTraversal(relative) ? null : relative;
|
||||
}
|
||||
|
||||
if (!normalizedPath.contains(':') && !normalizedPath.startsWith('/')) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _containsUnsafeTraversal(String value) {
|
||||
return value.split('/').any((segment) => segment == '..');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
String formatStorageHealthLabel(StorageGuardStatus? storage) {
|
||||
if (storage == null) {
|
||||
return '暂无集群数据';
|
||||
}
|
||||
|
||||
if (!storage.isEnabled) {
|
||||
return '存储守护未启用';
|
||||
}
|
||||
|
||||
final mappedMessage = _mapStorageMessage(storage.message);
|
||||
if (mappedMessage != null) {
|
||||
return mappedMessage;
|
||||
}
|
||||
|
||||
return storage.hasEnoughSpace ? '空间充足' : '空间不足';
|
||||
}
|
||||
|
||||
String formatStorageUsageLabel(StorageGuardStatus? storage) {
|
||||
if (storage == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final hasAvailableBytes = storage.availableBytes > 0;
|
||||
final hasRequiredBytes = storage.requiredBytes > 0;
|
||||
if (hasAvailableBytes || hasRequiredBytes) {
|
||||
final availableLabel = hasAvailableBytes ? formatBytes(storage.availableBytes) : '--';
|
||||
final requiredLabel = hasRequiredBytes ? formatBytes(storage.requiredBytes) : '--';
|
||||
return '可用 $availableLabel / 需保留 $requiredLabel';
|
||||
}
|
||||
|
||||
return _mapStorageMessage(storage.message) ?? '--';
|
||||
}
|
||||
|
||||
String? _mapStorageMessage(String rawMessage) {
|
||||
final normalized = rawMessage.trim().toLowerCase();
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized == 'storage is available' || normalized.contains('enough space')) {
|
||||
return '空间充足';
|
||||
}
|
||||
|
||||
if (normalized.contains('insufficient') ||
|
||||
normalized.contains('not enough') ||
|
||||
normalized.contains('low disk') ||
|
||||
normalized.contains('space is low')) {
|
||||
return '空间不足';
|
||||
}
|
||||
|
||||
if (normalized.contains('disabled')) {
|
||||
return '存储守护未启用';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
enum StatusTone {
|
||||
gray,
|
||||
green,
|
||||
blue,
|
||||
yellow,
|
||||
red,
|
||||
orange,
|
||||
indigo,
|
||||
}
|
||||
|
||||
const Map<int, String> availabilityLabelMap = <int, String>{
|
||||
0: '未知',
|
||||
1: '已下播',
|
||||
2: '直播中',
|
||||
};
|
||||
|
||||
const Map<int, String> recordingStateLabelMap = <int, String>{
|
||||
0: '已下播',
|
||||
1: '直播中',
|
||||
2: '录制中',
|
||||
};
|
||||
|
||||
const Map<int, String> taskStatusLabelMap = <int, String>{
|
||||
0: '待处理',
|
||||
1: '启动中',
|
||||
2: '录制中',
|
||||
3: '停止中',
|
||||
4: '已完成',
|
||||
5: '失败',
|
||||
6: '已停止',
|
||||
7: '处理中',
|
||||
};
|
||||
|
||||
const Map<int, String> logLevelLabelMap = <int, String>{
|
||||
0: '跟踪',
|
||||
1: '信息',
|
||||
2: '警告',
|
||||
3: '错误',
|
||||
};
|
||||
|
||||
const Map<int, String> outputFormatLabelMap = <int, String>{
|
||||
0: 'MP4',
|
||||
1: 'TS',
|
||||
};
|
||||
|
||||
const Map<int, String> saveModeLabelMap = <int, String>{
|
||||
0: '单文件',
|
||||
1: '分段',
|
||||
};
|
||||
|
||||
const Map<int, String> recordingTemplateLabelMap = <int, String>{
|
||||
0: '直接封装',
|
||||
1: '均衡 MP4',
|
||||
2: '归档 TS',
|
||||
};
|
||||
|
||||
const Map<String, String> qualityLabelMap = <String, String>{
|
||||
'origin': '原画',
|
||||
'FULL_HD': '超清',
|
||||
'HD': '高清',
|
||||
'SD': '标清',
|
||||
};
|
||||
|
||||
const Map<int, String> platformLabelMap = <int, String>{
|
||||
0: '未知',
|
||||
1: 'Douyin',
|
||||
2: 'Bilibili',
|
||||
3: 'Huya',
|
||||
4: 'Douyu',
|
||||
5: 'Kuaishou',
|
||||
6: 'TikTok',
|
||||
7: 'Xiaohongshu',
|
||||
8: 'YouTube',
|
||||
9: 'Twitch',
|
||||
10: 'PandaTV',
|
||||
11: 'Migu',
|
||||
};
|
||||
|
||||
const Map<int, String> uploadStatusLabelMap = <int, String>{
|
||||
0: '未上传',
|
||||
1: '已上传',
|
||||
2: '上传失败',
|
||||
};
|
||||
|
||||
const Map<String, String> autoStartDecisionLabelMap = <String, String>{
|
||||
'started': '已启动',
|
||||
'skipped_disabled': '已禁用',
|
||||
'skipped_storage': '存储不足',
|
||||
'skipped_active_session': '已有活动会话',
|
||||
'skipped_offline': '房间未开播',
|
||||
'skipped_debounce': '触发防抖中',
|
||||
'failed_startup': '启动失败',
|
||||
'poll_failed_transient': '轮询临时失败',
|
||||
'poll_failed': '轮询失败',
|
||||
};
|
||||
|
||||
String availabilityLabel(int? value) => availabilityLabelMap[value] ?? '未知';
|
||||
|
||||
String recordingStateLabel(int? value) => recordingStateLabelMap[value] ?? '未知';
|
||||
|
||||
String taskStatusLabel(int? value) => taskStatusLabelMap[value] ?? '未知';
|
||||
|
||||
String logLevelLabel(int? value) => logLevelLabelMap[value] ?? '未知';
|
||||
|
||||
String outputFormatLabel(int? value) => outputFormatLabelMap[value] ?? '--';
|
||||
|
||||
String saveModeLabel(int? value) => saveModeLabelMap[value] ?? '--';
|
||||
|
||||
String recordingTemplateLabel(int? value) => recordingTemplateLabelMap[value] ?? '--';
|
||||
|
||||
String qualityLabel(String? value) => qualityLabelMap[value] ?? (value == null || value.isEmpty ? '--' : value);
|
||||
|
||||
String platformLabel(int? value) => platformLabelMap[value] ?? '未知';
|
||||
|
||||
String uploadStatusLabel(int? value) => uploadStatusLabelMap[value] ?? '未知';
|
||||
|
||||
String autoStartDecisionLabel(String? value) =>
|
||||
autoStartDecisionLabelMap[value] ?? (value == null || value.isEmpty ? '暂无事件' : value);
|
||||
|
||||
bool isTaskActive(int? value) => value == 1 || value == 2 || value == 3 || value == 7;
|
||||
|
||||
bool isTaskFailed(int? value) => value == 5;
|
||||
|
||||
StatusTone toneForStatus({String? keyword, int? value, String? context}) {
|
||||
if (context == 'availability') {
|
||||
return value == 2 ? StatusTone.green : StatusTone.gray;
|
||||
}
|
||||
|
||||
if (context == 'recording') {
|
||||
if (value == 2) {
|
||||
return StatusTone.blue;
|
||||
}
|
||||
if (value == 1) {
|
||||
return StatusTone.green;
|
||||
}
|
||||
return StatusTone.gray;
|
||||
}
|
||||
|
||||
if (context == 'task' || context == 'session') {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return StatusTone.yellow;
|
||||
case 1:
|
||||
case 7:
|
||||
return StatusTone.indigo;
|
||||
case 2:
|
||||
return StatusTone.blue;
|
||||
case 3:
|
||||
return StatusTone.orange;
|
||||
case 4:
|
||||
return StatusTone.green;
|
||||
case 5:
|
||||
return StatusTone.red;
|
||||
default:
|
||||
return StatusTone.gray;
|
||||
}
|
||||
}
|
||||
|
||||
if (context == 'upload') {
|
||||
if (value == 1) {
|
||||
return StatusTone.green;
|
||||
}
|
||||
if (value == 2) {
|
||||
return StatusTone.red;
|
||||
}
|
||||
}
|
||||
|
||||
final normalized = keyword?.toLowerCase() ?? '';
|
||||
if (<String>['live', 'online', 'living', '直播中', 'completed', 'archived'].any(normalized.contains)) {
|
||||
return StatusTone.green;
|
||||
}
|
||||
if (<String>['recording', '录制中'].any(normalized.contains)) {
|
||||
return StatusTone.blue;
|
||||
}
|
||||
if (<String>['pending', 'queued', '待'].any(normalized.contains)) {
|
||||
return StatusTone.yellow;
|
||||
}
|
||||
if (<String>['retry', 'stopping', '停止中'].any(normalized.contains)) {
|
||||
return StatusTone.orange;
|
||||
}
|
||||
if (<String>['process', 'transcod'].any(normalized.contains)) {
|
||||
return StatusTone.indigo;
|
||||
}
|
||||
if (<String>['error', 'fail', '异常', '错误'].any(normalized.contains)) {
|
||||
return StatusTone.red;
|
||||
}
|
||||
return StatusTone.gray;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppCard extends StatelessWidget {
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(18),
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Card(
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap == null) {
|
||||
return card;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
this.title = '暂无数据',
|
||||
this.description = '当前没有可展示内容',
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(Icons.inbox_rounded, color: Color(0xFF2563EB), size: 28),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...<Widget>[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonal(
|
||||
onPressed: onAction,
|
||||
child: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class AppErrorCard extends StatelessWidget {
|
||||
const AppErrorCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.error_outline_rounded, color: Color(0xFFDC2626)),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppSearchBar extends StatelessWidget {
|
||||
const AppSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
this.onSubmitted,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onSubmitted: onSubmitted,
|
||||
onChanged: onChanged,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: controller.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onChanged?.call('');
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class MetricCard extends StatelessWidget {
|
||||
const MetricCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.description,
|
||||
this.color = const Color(0xFF2563EB),
|
||||
this.trendValue,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String description;
|
||||
final Color color;
|
||||
final double? trendValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = (trendValue ?? 0).clamp(0.05, 1.0);
|
||||
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: Text(
|
||||
description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
value: progress,
|
||||
color: color,
|
||||
backgroundColor: color.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileHeader extends StatelessWidget {
|
||||
const MobileHeader({
|
||||
super.key,
|
||||
required this.eyebrow,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.userInitials = 'L',
|
||||
this.onNotificationsPressed,
|
||||
this.onProfilePressed,
|
||||
});
|
||||
|
||||
final String eyebrow;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final String userInitials;
|
||||
final VoidCallback? onNotificationsPressed;
|
||||
final VoidCallback? onProfilePressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
eyebrow,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: onNotificationsPressed,
|
||||
icon: const Icon(Icons.notifications_none_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onProfilePressed,
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: const Color(0xFFE0ECFF),
|
||||
foregroundColor: const Color(0xFF2563EB),
|
||||
child: Text(
|
||||
userInitials,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trailing != null) ...<Widget>[
|
||||
const SizedBox(height: 16),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class SkeletonCard extends StatefulWidget {
|
||||
const SkeletonCard({
|
||||
super.key,
|
||||
this.height = 120,
|
||||
});
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
State<SkeletonCard> createState() => _SkeletonCardState();
|
||||
}
|
||||
|
||||
class _SkeletonCardState extends State<SkeletonCard> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
final opacity = 0.35 + (_controller.value * 0.4);
|
||||
return AppCard(
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE2E8F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
|
||||
class StatusBadge extends StatelessWidget {
|
||||
const StatusBadge({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.label,
|
||||
this.context,
|
||||
});
|
||||
|
||||
final Object? status;
|
||||
final String? label;
|
||||
final String? context;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tone = toneForStatus(
|
||||
value: status is int ? status as int : null,
|
||||
keyword: status?.toString(),
|
||||
context: this.context,
|
||||
);
|
||||
final style = _styleFor(tone);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: style.background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: style.border),
|
||||
),
|
||||
child: Text(
|
||||
label ?? status?.toString() ?? '--',
|
||||
style: TextStyle(
|
||||
color: style.foreground,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_BadgeStyle _styleFor(StatusTone tone) {
|
||||
switch (tone) {
|
||||
case StatusTone.green:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFECFDF5),
|
||||
foreground: Color(0xFF047857),
|
||||
border: Color(0xFFA7F3D0),
|
||||
);
|
||||
case StatusTone.blue:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFEFF6FF),
|
||||
foreground: Color(0xFF1D4ED8),
|
||||
border: Color(0xFFBFDBFE),
|
||||
);
|
||||
case StatusTone.yellow:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFFFBEB),
|
||||
foreground: Color(0xFFB45309),
|
||||
border: Color(0xFFFDE68A),
|
||||
);
|
||||
case StatusTone.red:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFEF2F2),
|
||||
foreground: Color(0xFFB91C1C),
|
||||
border: Color(0xFFFECACA),
|
||||
);
|
||||
case StatusTone.orange:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFFF7ED),
|
||||
foreground: Color(0xFFC2410C),
|
||||
border: Color(0xFFFED7AA),
|
||||
);
|
||||
case StatusTone.indigo:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFEEF2FF),
|
||||
foreground: Color(0xFF4338CA),
|
||||
border: Color(0xFFC7D2FE),
|
||||
);
|
||||
case StatusTone.gray:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFF1F5F9),
|
||||
foreground: Color(0xFF475569),
|
||||
border: Color(0xFFE2E8F0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgeStyle {
|
||||
const _BadgeStyle({
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.border,
|
||||
});
|
||||
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final Color border;
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
|
||||
|
||||
abstract interface class SessionControllerHandle extends Listenable {
|
||||
bool get isRestoring;
|
||||
bool get isLoggedIn;
|
||||
|
||||
Future<void> restore();
|
||||
Future<void> clearLocalSession();
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class AppSessionController extends ChangeNotifier implements SessionControllerHandle {
|
||||
AppSessionController({
|
||||
required AuthRepository authRepository,
|
||||
required SessionStorage sessionStorage,
|
||||
}) : _authRepository = authRepository,
|
||||
_sessionStorage = sessionStorage;
|
||||
|
||||
final AuthRepository _authRepository;
|
||||
final SessionStorage _sessionStorage;
|
||||
|
||||
LoginResponse? _session;
|
||||
bool _isRestoring = true;
|
||||
|
||||
@override
|
||||
bool get isRestoring => _isRestoring;
|
||||
@override
|
||||
bool get isLoggedIn => token != null && token!.isNotEmpty;
|
||||
String? get token => _session?.token;
|
||||
AuthenticatedUser? get user => _session?.user;
|
||||
LoginResponse? get session => _session;
|
||||
|
||||
@override
|
||||
Future<void> restore() async {
|
||||
_isRestoring = true;
|
||||
notifyListeners();
|
||||
|
||||
final persisted = await _sessionStorage.read();
|
||||
if (persisted != null) {
|
||||
_session = LoginResponse.fromJson(persisted);
|
||||
}
|
||||
|
||||
_isRestoring = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final session = await _authRepository.login(
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_session = session;
|
||||
await _sessionStorage.write(session.toJson());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _authRepository.logout();
|
||||
} finally {
|
||||
await clearLocalSession();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) {
|
||||
return _authRepository.changePassword(
|
||||
currentPassword: currentPassword,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleUnauthorized() async {
|
||||
await clearLocalSession();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearLocalSession() async {
|
||||
_session = null;
|
||||
await _sessionStorage.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/logs_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
|
||||
|
||||
import 'main_controllers.dart';
|
||||
|
||||
class RoomDetailController extends BaseController {
|
||||
RoomDetailController({
|
||||
required LiveRoomsRepository liveRoomsRepository,
|
||||
required RecordingsRepository recordingsRepository,
|
||||
required RecoveryRepository recoveryRepository,
|
||||
required this.roomId,
|
||||
}) : _liveRoomsRepository = liveRoomsRepository,
|
||||
_recordingsRepository = recordingsRepository,
|
||||
_recoveryRepository = recoveryRepository;
|
||||
|
||||
final LiveRoomsRepository _liveRoomsRepository;
|
||||
final RecordingsRepository _recordingsRepository;
|
||||
final RecoveryRepository _recoveryRepository;
|
||||
final String roomId;
|
||||
|
||||
LiveRoom? room;
|
||||
List<RecordSession> sessions = const <RecordSession>[];
|
||||
RecoveryOverview? recoveryOverview;
|
||||
|
||||
@override
|
||||
bool get hasData => room != null || sessions.isNotEmpty;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
final results = await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_liveRoomsRepository.getRoom(roomId),
|
||||
_recordingsRepository.listSessions(liveRoomId: roomId),
|
||||
_recoveryRepository.getOverview(),
|
||||
]);
|
||||
room = results[0] as LiveRoom;
|
||||
sessions = results[1] as List<RecordSession>;
|
||||
recoveryOverview = results[2] as RecoveryOverview;
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
RecoverableLiveRoom? get recoveryInfo {
|
||||
try {
|
||||
return recoveryOverview?.liveRooms.firstWhere((RecoverableLiveRoom item) => item.liveRoomId == roomId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingDetailController extends BaseController {
|
||||
RecordingDetailController({
|
||||
required RecordingsRepository recordingsRepository,
|
||||
required SettingsRepository settingsRepository,
|
||||
required MediaRepository mediaRepository,
|
||||
required this.taskId,
|
||||
}) : _recordingsRepository = recordingsRepository,
|
||||
_settingsRepository = settingsRepository,
|
||||
_mediaRepository = mediaRepository;
|
||||
|
||||
final RecordingsRepository _recordingsRepository;
|
||||
final SettingsRepository _settingsRepository;
|
||||
final MediaRepository _mediaRepository;
|
||||
final String taskId;
|
||||
|
||||
RecordTaskDetail? detail;
|
||||
SystemSettings? settings;
|
||||
|
||||
@override
|
||||
bool get hasData => detail != null;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
final results = await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_recordingsRepository.getTaskDetail(taskId),
|
||||
_settingsRepository.getSettings(),
|
||||
]);
|
||||
detail = results[0] as RecordTaskDetail;
|
||||
settings = results[1] as SystemSettings;
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
Future<RecordPreviewTicket> createPreviewTicket() {
|
||||
return _recordingsRepository.createPreviewTicket(taskId);
|
||||
}
|
||||
|
||||
Uri? get downloadUri {
|
||||
final task = detail?.task;
|
||||
if (task == null) {
|
||||
return null;
|
||||
}
|
||||
final relativePath = deriveRelativeMediaPath(
|
||||
outputRoot: settings?.outputRoot,
|
||||
outputFilePath: task.outputFilePath,
|
||||
);
|
||||
if (relativePath == null || relativePath.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _mediaRepository.buildFileUri(relativePath: relativePath, download: true);
|
||||
}
|
||||
}
|
||||
|
||||
class LogsController extends BaseController {
|
||||
LogsController({
|
||||
required LogsRepository logsRepository,
|
||||
}) : _logsRepository = logsRepository;
|
||||
|
||||
final LogsRepository _logsRepository;
|
||||
|
||||
List<SystemLog> logs = const <SystemLog>[];
|
||||
int? level;
|
||||
String query = '';
|
||||
|
||||
@override
|
||||
bool get hasData => logs.isNotEmpty;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
logs = await _logsRepository.listLogs(
|
||||
level: level,
|
||||
content: query,
|
||||
);
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
void setLevel(int? value) {
|
||||
level = value;
|
||||
safeNotify();
|
||||
}
|
||||
|
||||
void setQuery(String value) {
|
||||
query = value;
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
class StorageController extends BaseController {
|
||||
StorageController({
|
||||
required SettingsRepository settingsRepository,
|
||||
required RecoveryRepository recoveryRepository,
|
||||
}) : _settingsRepository = settingsRepository,
|
||||
_recoveryRepository = recoveryRepository;
|
||||
|
||||
final SettingsRepository _settingsRepository;
|
||||
final RecoveryRepository _recoveryRepository;
|
||||
|
||||
SystemSettings? settings;
|
||||
RecoveryOverview? recoveryOverview;
|
||||
|
||||
@override
|
||||
bool get hasData => settings != null || recoveryOverview != null;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
final results = await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_settingsRepository.getSettings(),
|
||||
_recoveryRepository.getOverview(),
|
||||
]);
|
||||
settings = results[0] as SystemSettings;
|
||||
recoveryOverview = results[1] as RecoveryOverview;
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
Future<CleanupOperation> runRetentionCleanup() {
|
||||
return _settingsRepository.runRetentionCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
class MediaBrowserController extends BaseController {
|
||||
MediaBrowserController({
|
||||
required MediaRepository mediaRepository,
|
||||
}) : _mediaRepository = mediaRepository;
|
||||
|
||||
final MediaRepository _mediaRepository;
|
||||
|
||||
MediaBrowserResponse? response;
|
||||
String currentPath = '';
|
||||
|
||||
@override
|
||||
bool get hasData => response != null;
|
||||
|
||||
Future<void> refresh({bool silent = false, String? path}) {
|
||||
return runLoad(() async {
|
||||
currentPath = path ?? currentPath;
|
||||
response = await _mediaRepository.browse(path: currentPath);
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
Uri fileUri(String relativePath, {bool download = false}) {
|
||||
return _mediaRepository.buildFileUri(relativePath: relativePath, download: download);
|
||||
}
|
||||
|
||||
Future<String> transcodeFile(String relativePath) {
|
||||
return _mediaRepository.transcodeFile(relativePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:live_recorder_mobile/core/network/api_exception.dart';
|
||||
import 'package:live_recorder_mobile/core/polling/polling_controller.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
|
||||
|
||||
enum RoomFilter {
|
||||
all,
|
||||
live,
|
||||
recording,
|
||||
error,
|
||||
retrying,
|
||||
}
|
||||
|
||||
abstract class BaseController extends ChangeNotifier {
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
bool _disposed = false;
|
||||
|
||||
bool get hasData => false;
|
||||
|
||||
@protected
|
||||
void safeNotify() {
|
||||
if (!_disposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
Future<void> runLoad(
|
||||
Future<void> Function() action, {
|
||||
bool silent = false,
|
||||
}) async {
|
||||
if (!silent) {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
safeNotify();
|
||||
}
|
||||
|
||||
try {
|
||||
await action();
|
||||
errorMessage = null;
|
||||
} on ApiException catch (error) {
|
||||
if (!silent || !hasData) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent || !hasData) {
|
||||
errorMessage = error.toString();
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
isLoading = false;
|
||||
}
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardController extends BaseController {
|
||||
DashboardController({
|
||||
required LiveRoomsRepository liveRoomsRepository,
|
||||
required RecordingsRepository recordingsRepository,
|
||||
required RecoveryRepository recoveryRepository,
|
||||
}) : _liveRoomsRepository = liveRoomsRepository,
|
||||
_recordingsRepository = recordingsRepository,
|
||||
_recoveryRepository = recoveryRepository {
|
||||
_polling = PollingController(
|
||||
interval: const Duration(seconds: 15),
|
||||
onTick: () => refresh(silent: true),
|
||||
);
|
||||
}
|
||||
|
||||
final LiveRoomsRepository _liveRoomsRepository;
|
||||
final RecordingsRepository _recordingsRepository;
|
||||
final RecoveryRepository _recoveryRepository;
|
||||
late final PollingController _polling;
|
||||
|
||||
List<LiveRoom> rooms = const <LiveRoom>[];
|
||||
List<RecordSession> sessions = const <RecordSession>[];
|
||||
List<RecordTask> tasks = const <RecordTask>[];
|
||||
RecoveryOverview? recoveryOverview;
|
||||
|
||||
@override
|
||||
bool get hasData => rooms.isNotEmpty || sessions.isNotEmpty || tasks.isNotEmpty || recoveryOverview != null;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
final results = await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_liveRoomsRepository.listRooms(),
|
||||
_recordingsRepository.listSessions(),
|
||||
_recordingsRepository.listTasks(),
|
||||
_recoveryRepository.getOverview(),
|
||||
]);
|
||||
|
||||
rooms = (results[0] as List<LiveRoom>)
|
||||
..sort((LiveRoom a, LiveRoom b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
sessions = results[1] as List<RecordSession>;
|
||||
tasks = (results[2] as List<RecordTask>)
|
||||
..sort((RecordTask a, RecordTask b) => b.createdAt.compareTo(a.createdAt));
|
||||
recoveryOverview = results[3] as RecoveryOverview;
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
void setActive(bool active) {
|
||||
_polling.setActive(active);
|
||||
}
|
||||
|
||||
int get onlineRoomCount => rooms.where((LiveRoom room) => room.availabilityStatus == 2).length;
|
||||
|
||||
int get activeRecordingTaskCount => tasks.where((RecordTask task) => isTaskActive(task.status)).length;
|
||||
|
||||
int get todayRecordingCount {
|
||||
final now = DateTime.now();
|
||||
return tasks.where((RecordTask task) {
|
||||
final createdAt = DateTime.tryParse(task.createdAt)?.toLocal();
|
||||
return createdAt != null &&
|
||||
createdAt.year == now.year &&
|
||||
createdAt.month == now.month &&
|
||||
createdAt.day == now.day;
|
||||
}).length;
|
||||
}
|
||||
|
||||
int get alertCount {
|
||||
final recoveryCount = (recoveryOverview?.liveRooms.length ?? 0) + (recoveryOverview?.finalizations.length ?? 0);
|
||||
final failedTasks = tasks.where((RecordTask task) => isTaskFailed(task.status)).length;
|
||||
return recoveryCount + failedTasks;
|
||||
}
|
||||
|
||||
String get clusterHealthLabel {
|
||||
final storage = recoveryOverview?.storage;
|
||||
if (storage == null) {
|
||||
return '暂无集群数据';
|
||||
}
|
||||
if (!storage.isEnabled) {
|
||||
return '存储守护未启用';
|
||||
}
|
||||
if (storage.message.trim().isNotEmpty) {
|
||||
return storage.message;
|
||||
}
|
||||
return storage.hasEnoughSpace ? '存储空间正常' : '存储空间告警';
|
||||
}
|
||||
|
||||
String get clusterNodeCountLabel => '--';
|
||||
|
||||
String get concurrentRecordingLabel =>
|
||||
'${sessions.where((RecordSession session) => isTaskActive(session.status)).length}';
|
||||
|
||||
String get storageUsageLabel {
|
||||
final storage = recoveryOverview?.storage;
|
||||
if (storage == null) {
|
||||
return '--';
|
||||
}
|
||||
return storage.message.trim().isEmpty ? '--' : storage.message;
|
||||
}
|
||||
|
||||
List<int> get throughputBuckets {
|
||||
final now = DateTime.now();
|
||||
final buckets = List<int>.filled(8, 0);
|
||||
|
||||
for (final RecordTask task in tasks) {
|
||||
final parsed = DateTime.tryParse(task.startedAt ?? task.createdAt)?.toLocal();
|
||||
if (parsed == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final diff = now.difference(parsed);
|
||||
if (diff.inHours < 0 || diff.inHours >= 8) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final index = 7 - diff.inHours;
|
||||
buckets[index] += 1;
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
List<LiveRoom> get focusRooms {
|
||||
final prioritized = rooms.where((LiveRoom room) => room.isPinned || room.isPriority).toList(growable: false);
|
||||
if (prioritized.isNotEmpty) {
|
||||
return prioritized.take(4).toList(growable: false);
|
||||
}
|
||||
return rooms.take(4).toList(growable: false);
|
||||
}
|
||||
|
||||
RecordSession? activeSessionForRoom(String roomId) {
|
||||
try {
|
||||
return sessions.firstWhere(
|
||||
(RecordSession session) => session.liveRoomId == roomId && isTaskActive(session.status),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_polling.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class RoomsController extends BaseController {
|
||||
RoomsController({
|
||||
required LiveRoomsRepository liveRoomsRepository,
|
||||
required RecordingsRepository recordingsRepository,
|
||||
required RecoveryRepository recoveryRepository,
|
||||
}) : _liveRoomsRepository = liveRoomsRepository,
|
||||
_recordingsRepository = recordingsRepository,
|
||||
_recoveryRepository = recoveryRepository {
|
||||
_polling = PollingController(
|
||||
interval: const Duration(seconds: 15),
|
||||
onTick: () => refresh(silent: true),
|
||||
);
|
||||
}
|
||||
|
||||
final LiveRoomsRepository _liveRoomsRepository;
|
||||
final RecordingsRepository _recordingsRepository;
|
||||
final RecoveryRepository _recoveryRepository;
|
||||
late final PollingController _polling;
|
||||
|
||||
List<LiveRoom> rooms = const <LiveRoom>[];
|
||||
List<RecordSession> sessions = const <RecordSession>[];
|
||||
RecoveryOverview? recoveryOverview;
|
||||
String query = '';
|
||||
RoomFilter filter = RoomFilter.all;
|
||||
String? busyRoomId;
|
||||
|
||||
@override
|
||||
bool get hasData => rooms.isNotEmpty || sessions.isNotEmpty || recoveryOverview != null;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
final results = await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_liveRoomsRepository.listRooms(),
|
||||
_recordingsRepository.listSessions(),
|
||||
_recoveryRepository.getOverview(),
|
||||
]);
|
||||
|
||||
sessions = results[1] as List<RecordSession>;
|
||||
recoveryOverview = results[2] as RecoveryOverview;
|
||||
rooms = (results[0] as List<LiveRoom>)..sort(compareRooms);
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
@protected
|
||||
int compareRooms(LiveRoom a, LiveRoom b) {
|
||||
final priorityA = (a.isPinned || a.isPriority) ? 1 : 0;
|
||||
final priorityB = (b.isPinned || b.isPriority) ? 1 : 0;
|
||||
if (priorityA != priorityB) {
|
||||
return priorityB.compareTo(priorityA);
|
||||
}
|
||||
return b.updatedAt.compareTo(a.updatedAt);
|
||||
}
|
||||
|
||||
void setActive(bool active) {
|
||||
_polling.setActive(active);
|
||||
}
|
||||
|
||||
void setQuery(String value) {
|
||||
query = value;
|
||||
safeNotify();
|
||||
}
|
||||
|
||||
void setFilter(RoomFilter value) {
|
||||
filter = value;
|
||||
safeNotify();
|
||||
}
|
||||
|
||||
List<LiveRoom> get filteredRooms {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
return rooms.where((LiveRoom room) {
|
||||
if (normalizedQuery.isNotEmpty) {
|
||||
final searchPool = <String>[
|
||||
room.title ?? '',
|
||||
room.anchorName ?? '',
|
||||
room.roomId,
|
||||
room.platformName,
|
||||
room.alias ?? '',
|
||||
recentEventForRoom(room),
|
||||
].join(' ').toLowerCase();
|
||||
if (!searchPool.contains(normalizedQuery)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case RoomFilter.all:
|
||||
return true;
|
||||
case RoomFilter.live:
|
||||
return room.availabilityStatus == 2;
|
||||
case RoomFilter.recording:
|
||||
return room.currentRecordingState == 2;
|
||||
case RoomFilter.error:
|
||||
return roomHasError(room);
|
||||
case RoomFilter.retrying:
|
||||
return roomIsRetrying(room);
|
||||
}
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
RecordSession? sessionForRoom(String roomId) {
|
||||
final matchingSessions = sessions.where((RecordSession session) => session.liveRoomId == roomId).toList(growable: false);
|
||||
if (matchingSessions.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
matchingSessions.sort((RecordSession a, RecordSession b) => (b.startedAt ?? b.createdAt).compareTo(a.startedAt ?? a.createdAt));
|
||||
return matchingSessions.first;
|
||||
}
|
||||
|
||||
RecoverableLiveRoom? recoveryInfoForRoom(String roomId) {
|
||||
try {
|
||||
return recoveryOverview?.liveRooms.firstWhere((RecoverableLiveRoom item) => item.liveRoomId == roomId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String recentEventForRoom(LiveRoom room) {
|
||||
final recoveryInfo = recoveryInfoForRoom(room.id);
|
||||
return recoveryInfo?.lastAutoStartDecisionSummary ??
|
||||
room.lastAutoStartDecisionSummary ??
|
||||
autoStartDecisionLabel(recoveryInfo?.lastAutoStartDecisionCode ?? room.lastAutoStartDecisionCode);
|
||||
}
|
||||
|
||||
bool roomHasError(LiveRoom room) {
|
||||
final code = (room.lastAutoStartDecisionCode ?? '').toLowerCase();
|
||||
return recoveryInfoForRoom(room.id) != null || code.contains('fail') || code.contains('error');
|
||||
}
|
||||
|
||||
bool roomIsRetrying(LiveRoom room) {
|
||||
final code = (room.lastAutoStartDecisionCode ?? '').toLowerCase();
|
||||
return code.contains('retry');
|
||||
}
|
||||
|
||||
Future<String> createRoom({
|
||||
required String url,
|
||||
int? platformOverride,
|
||||
}) async {
|
||||
await _liveRoomsRepository.createRoom(url: url, platformOverride: platformOverride);
|
||||
await refresh(silent: true);
|
||||
return '直播间已添加';
|
||||
}
|
||||
|
||||
Future<String> toggleRoomEnabled(LiveRoom room) async {
|
||||
busyRoomId = room.id;
|
||||
safeNotify();
|
||||
try {
|
||||
await _liveRoomsRepository.setRoomEnabled(
|
||||
roomId: room.id,
|
||||
isEnabled: !room.isEnabled,
|
||||
);
|
||||
await refresh(silent: true);
|
||||
return room.isEnabled ? '直播间已停用' : '直播间已启用';
|
||||
} finally {
|
||||
busyRoomId = null;
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> refreshRoom(LiveRoom room) async {
|
||||
busyRoomId = room.id;
|
||||
safeNotify();
|
||||
try {
|
||||
await _liveRoomsRepository.refreshRoom(room.id);
|
||||
await refresh(silent: true);
|
||||
return '直播状态已刷新';
|
||||
} finally {
|
||||
busyRoomId = null;
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> startRecording({
|
||||
required LiveRoom room,
|
||||
String? preferredQuality,
|
||||
int? outputFormat,
|
||||
}) async {
|
||||
busyRoomId = room.id;
|
||||
safeNotify();
|
||||
try {
|
||||
await _recordingsRepository.startRecording(
|
||||
liveRoomId: room.id,
|
||||
preferredQuality: preferredQuality ?? room.effectiveSettings.preferredQuality,
|
||||
outputFormat: outputFormat ?? room.effectiveSettings.outputFormat,
|
||||
);
|
||||
await refresh(silent: true);
|
||||
return '录制任务已启动';
|
||||
} finally {
|
||||
busyRoomId = null;
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> retryRoom(LiveRoom room) async {
|
||||
busyRoomId = room.id;
|
||||
safeNotify();
|
||||
try {
|
||||
await _recoveryRepository.retryLiveRoom(room.id);
|
||||
await refresh(silent: true);
|
||||
return '已提交重试请求';
|
||||
} finally {
|
||||
busyRoomId = null;
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> saveMetadata({
|
||||
required LiveRoom room,
|
||||
required String? remark,
|
||||
required bool isPinned,
|
||||
required String? alias,
|
||||
required bool isPriority,
|
||||
required int? pollingIntervalSecondsOverride,
|
||||
}) async {
|
||||
busyRoomId = room.id;
|
||||
safeNotify();
|
||||
try {
|
||||
await _liveRoomsRepository.updateMetadata(
|
||||
roomId: room.id,
|
||||
payload: <String, dynamic>{
|
||||
'remark': remark?.trim().isEmpty ?? true ? null : remark?.trim(),
|
||||
'isPinned': isPinned,
|
||||
'alias': alias?.trim().isEmpty ?? true ? null : alias?.trim(),
|
||||
'isPriority': isPriority,
|
||||
'pollingIntervalSecondsOverride': pollingIntervalSecondsOverride,
|
||||
},
|
||||
);
|
||||
await refresh(silent: true);
|
||||
return '房间信息已保存';
|
||||
} finally {
|
||||
busyRoomId = null;
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> saveRoomSettings({
|
||||
required LiveRoom room,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
busyRoomId = room.id;
|
||||
safeNotify();
|
||||
try {
|
||||
await _liveRoomsRepository.updateSettings(roomId: room.id, payload: payload);
|
||||
await refresh(silent: true);
|
||||
return '录制设置已保存';
|
||||
} finally {
|
||||
busyRoomId = null;
|
||||
safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_polling.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class MonitorController extends RoomsController {
|
||||
MonitorController({
|
||||
required super.liveRoomsRepository,
|
||||
required super.recordingsRepository,
|
||||
required super.recoveryRepository,
|
||||
});
|
||||
|
||||
@override
|
||||
int compareRooms(LiveRoom a, LiveRoom b) => compareMonitorRooms(a, b);
|
||||
}
|
||||
|
||||
class RecordingsController extends BaseController {
|
||||
RecordingsController({
|
||||
required RecordingsRepository recordingsRepository,
|
||||
required SettingsRepository settingsRepository,
|
||||
required MediaRepository mediaRepository,
|
||||
}) : _recordingsRepository = recordingsRepository,
|
||||
_settingsRepository = settingsRepository,
|
||||
_mediaRepository = mediaRepository {
|
||||
_polling = PollingController(
|
||||
interval: const Duration(seconds: 15),
|
||||
onTick: () => refresh(silent: true),
|
||||
);
|
||||
}
|
||||
|
||||
final RecordingsRepository _recordingsRepository;
|
||||
final SettingsRepository _settingsRepository;
|
||||
final MediaRepository _mediaRepository;
|
||||
late final PollingController _polling;
|
||||
|
||||
List<RecordTask> tasks = const <RecordTask>[];
|
||||
SystemSettings? settings;
|
||||
final Map<String, RecordTaskDetail> detailCache = <String, RecordTaskDetail>{};
|
||||
String query = '';
|
||||
|
||||
@override
|
||||
bool get hasData => tasks.isNotEmpty || settings != null;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
final results = await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_recordingsRepository.listTasks(),
|
||||
_settingsRepository.getSettings(),
|
||||
]);
|
||||
tasks = (results[0] as List<RecordTask>)
|
||||
..sort((RecordTask a, RecordTask b) => b.createdAt.compareTo(a.createdAt));
|
||||
settings = results[1] as SystemSettings;
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
void setActive(bool active) {
|
||||
_polling.setActive(active);
|
||||
}
|
||||
|
||||
void setQuery(String value) {
|
||||
query = value;
|
||||
safeNotify();
|
||||
}
|
||||
|
||||
List<RecordTask> get filteredTasks {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
return tasks.where((RecordTask task) {
|
||||
final searchPool = <String>[
|
||||
task.liveRoomTitle,
|
||||
task.roomId,
|
||||
task.outputFilePath ?? '',
|
||||
].join(' ').toLowerCase();
|
||||
return searchPool.contains(normalizedQuery);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
RecordTaskDetail? cachedDetail(String taskId) => detailCache[taskId];
|
||||
|
||||
Future<void> ensureDetailLoaded(String taskId) async {
|
||||
if (detailCache.containsKey(taskId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final detail = await _recordingsRepository.getTaskDetail(taskId);
|
||||
detailCache[taskId] = detail;
|
||||
safeNotify();
|
||||
} catch (_) {
|
||||
// Keep lightweight list rendering resilient.
|
||||
}
|
||||
}
|
||||
|
||||
Uri? downloadUriForTask(RecordTask task) {
|
||||
final relativePath = deriveRelativeMediaPath(
|
||||
outputRoot: settings?.outputRoot,
|
||||
outputFilePath: task.outputFilePath,
|
||||
);
|
||||
if (relativePath == null || relativePath.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _mediaRepository.buildFileUri(
|
||||
relativePath: relativePath,
|
||||
download: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_polling.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileController extends BaseController {
|
||||
ProfileController({
|
||||
required SettingsRepository settingsRepository,
|
||||
required RecoveryRepository recoveryRepository,
|
||||
}) : _settingsRepository = settingsRepository,
|
||||
_recoveryRepository = recoveryRepository;
|
||||
|
||||
final SettingsRepository _settingsRepository;
|
||||
final RecoveryRepository _recoveryRepository;
|
||||
|
||||
SystemSettings? settings;
|
||||
RecoveryOverview? recoveryOverview;
|
||||
|
||||
@override
|
||||
bool get hasData => settings != null || recoveryOverview != null;
|
||||
|
||||
Future<void> refresh({bool silent = false}) {
|
||||
return runLoad(() async {
|
||||
final results = await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_settingsRepository.getSettings(),
|
||||
_recoveryRepository.getOverview(),
|
||||
]);
|
||||
settings = results[0] as SystemSettings;
|
||||
recoveryOverview = results[1] as RecoveryOverview;
|
||||
}, silent: silent);
|
||||
}
|
||||
|
||||
Future<SystemSettings> saveNotificationSettings(SystemSettings updated) async {
|
||||
final saved = await _settingsRepository.updateSettings(updated);
|
||||
settings = saved;
|
||||
safeNotify();
|
||||
return saved;
|
||||
}
|
||||
|
||||
Future<CleanupOperation> runRetentionCleanup() {
|
||||
return _settingsRepository.runRetentionCleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/backend_address_form_card.dart';
|
||||
|
||||
class BackendSettingsPage extends StatefulWidget {
|
||||
const BackendSettingsPage({
|
||||
super.key,
|
||||
required this.bootstrapController,
|
||||
});
|
||||
|
||||
final BackendConfigHandle bootstrapController;
|
||||
|
||||
@override
|
||||
State<BackendSettingsPage> createState() => _BackendSettingsPageState();
|
||||
}
|
||||
|
||||
class _BackendSettingsPageState extends State<BackendSettingsPage> {
|
||||
late final TextEditingController _controller = TextEditingController(
|
||||
text: widget.bootstrapController.backendBaseUrl ?? widget.bootstrapController.seedBaseUrl,
|
||||
);
|
||||
|
||||
bool _submitting = false;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
final validationMessage = validateBackendBaseUrl(_controller.text);
|
||||
if (validationMessage != null) {
|
||||
setState(() {
|
||||
_errorText = validationMessage;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedValue = normalizeBackendBaseUrl(_controller.text);
|
||||
if (normalizedValue == widget.bootstrapController.backendBaseUrl) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('切换后端地址'),
|
||||
content: const Text(
|
||||
'修改后端地址后,当前登录状态会被清空,并返回登录页重新连接。是否继续?',
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('确认切换'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
) ??
|
||||
false;
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final changed = await widget.bootstrapController.updateBackendBaseUrl(normalizedValue);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (!changed) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).popUntil((Route<dynamic> route) => route.isFirst);
|
||||
} on FormatException catch (error) {
|
||||
setState(() {
|
||||
_errorText = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
_errorText = '保存后端地址失败:$error';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentBaseUrl = widget.bootstrapController.backendBaseUrl ?? '--';
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('连接设置'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'当前后端地址',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SelectableText(
|
||||
currentBaseUrl,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF2563EB),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
BackendAddressFormCard(
|
||||
title: '修改后端地址',
|
||||
description: '你可以在这里切换到新的 LiveRecorder 后端环境。地址保存成功后,应用会自动清空当前登录态并返回登录页。',
|
||||
note: '此操作不会修改后端接口,只会切换移动端请求的基础地址。',
|
||||
controller: _controller,
|
||||
actionLabel: '保存并切换',
|
||||
onSubmit: _submit,
|
||||
isSubmitting: _submitting,
|
||||
errorText: _errorText,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/backend_address_form_card.dart';
|
||||
|
||||
class BackendSetupPage extends StatefulWidget {
|
||||
const BackendSetupPage({
|
||||
super.key,
|
||||
required this.bootstrapController,
|
||||
});
|
||||
|
||||
final BackendConfigHandle bootstrapController;
|
||||
|
||||
@override
|
||||
State<BackendSetupPage> createState() => _BackendSetupPageState();
|
||||
}
|
||||
|
||||
class _BackendSetupPageState extends State<BackendSetupPage> {
|
||||
late final TextEditingController _controller = TextEditingController(
|
||||
text: widget.bootstrapController.backendBaseUrl ?? widget.bootstrapController.seedBaseUrl,
|
||||
);
|
||||
|
||||
bool _submitting = false;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
final validationMessage = validateBackendBaseUrl(_controller.text);
|
||||
if (validationMessage != null) {
|
||||
setState(() {
|
||||
_errorText = validationMessage;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.bootstrapController.saveInitialBackendBaseUrl(_controller.text);
|
||||
} on FormatException catch (error) {
|
||||
setState(() {
|
||||
_errorText = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
_errorText = '保存后端地址失败:$error';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final isWide = constraints.maxWidth >= 900;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: isWide
|
||||
? Row(
|
||||
children: <Widget>[
|
||||
Expanded(child: _buildHero()),
|
||||
const SizedBox(width: 32),
|
||||
SizedBox(
|
||||
width: 460,
|
||||
child: _buildForm(),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView(
|
||||
children: <Widget>[
|
||||
_buildHero(),
|
||||
const SizedBox(height: 24),
|
||||
_buildForm(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'LiveRecorder',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2563EB),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'首次进入先连接你的后端服务',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 38,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.08,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'配置完成后,应用会继续使用现有登录、Token 和真实接口。以后也可以在“我的 > 连接设置”里随时修改后端地址。',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: const <Widget>[
|
||||
_HeroChip(label: '真实接口接入'),
|
||||
_HeroChip(label: '保留现有认证'),
|
||||
_HeroChip(label: '支持子路径部署'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm() {
|
||||
return BackendAddressFormCard(
|
||||
title: '配置后端地址',
|
||||
description: '请输入 LiveRecorder 后端的完整访问地址。保存后会进入登录流程,不会写入任何 mock 数据。',
|
||||
note: widget.bootstrapController.seedBaseUrl.isEmpty
|
||||
? null
|
||||
: '已检测到启动参数中的默认地址,当前已为你预填,可直接修改后保存。',
|
||||
controller: _controller,
|
||||
actionLabel: '保存并继续',
|
||||
onSubmit: _submit,
|
||||
isSubmitting: _submitting,
|
||||
errorText: _errorText,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroChip extends StatelessWidget {
|
||||
const _HeroChip({
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/recovery_formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/metric_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/cluster_status_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_card.dart';
|
||||
|
||||
class DashboardPage extends StatefulWidget {
|
||||
const DashboardPage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.userInitials,
|
||||
required this.onOpenLogs,
|
||||
required this.onOpenProfile,
|
||||
});
|
||||
|
||||
final DashboardController controller;
|
||||
final String userInitials;
|
||||
final VoidCallback onOpenLogs;
|
||||
final VoidCallback onOpenProfile;
|
||||
|
||||
@override
|
||||
State<DashboardPage> createState() => _DashboardPageState();
|
||||
}
|
||||
|
||||
class _DashboardPageState extends State<DashboardPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
final buckets = widget.controller.throughputBuckets;
|
||||
final hasThroughput = buckets.any((int value) => value > 0);
|
||||
final maxBucket = hasThroughput
|
||||
? buckets.reduce((int a, int b) => a > b ? a : b)
|
||||
: 0;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: <Widget>[
|
||||
MobileHeader(
|
||||
eyebrow: 'LiveRecorder · 安卓端',
|
||||
title: '监控大盘',
|
||||
userInitials: widget.userInitials,
|
||||
onNotificationsPressed: widget.onOpenLogs,
|
||||
onProfilePressed: widget.onOpenProfile,
|
||||
),
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
SkeletonCard(height: 140),
|
||||
SizedBox(height: 16),
|
||||
SkeletonCard(height: 180),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (widget.controller.errorMessage != null &&
|
||||
!widget.controller.hasData)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh();
|
||||
},
|
||||
),
|
||||
)
|
||||
else ...<Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ClusterStatusCard(
|
||||
healthLabel: formatStorageHealthLabel(
|
||||
widget.controller.recoveryOverview?.storage,
|
||||
),
|
||||
nodeCountLabel: widget.controller.clusterNodeCountLabel,
|
||||
concurrentRecordingLabel:
|
||||
widget.controller.concurrentRecordingLabel,
|
||||
storageLabel: formatStorageUsageLabel(
|
||||
widget.controller.recoveryOverview?.storage,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: 4,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
mainAxisExtent: 180,
|
||||
),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final cards = <Widget>[
|
||||
MetricCard(
|
||||
label: '在线直播间',
|
||||
value: '${widget.controller.onlineRoomCount}',
|
||||
description: '来自 /api/live-rooms 的实时状态统计',
|
||||
trendValue: widget.controller.rooms.isEmpty
|
||||
? 0.08
|
||||
: widget.controller.onlineRoomCount /
|
||||
widget.controller.rooms.length,
|
||||
),
|
||||
MetricCard(
|
||||
label: '录制中任务',
|
||||
value:
|
||||
'${widget.controller.activeRecordingTaskCount}',
|
||||
description: '启动中、录制中、处理中任务总数',
|
||||
trendValue: widget.controller.tasks.isEmpty
|
||||
? 0.08
|
||||
: widget.controller.activeRecordingTaskCount /
|
||||
widget.controller.tasks.length,
|
||||
),
|
||||
MetricCard(
|
||||
label: '今日新增录像',
|
||||
value: '${widget.controller.todayRecordingCount}',
|
||||
description: '基于真实 task.createdAt 统计',
|
||||
trendValue:
|
||||
widget.controller.todayRecordingCount == 0
|
||||
? 0.08
|
||||
: 0.45,
|
||||
),
|
||||
MetricCard(
|
||||
label: '异常告警',
|
||||
value: '${widget.controller.alertCount}',
|
||||
description: '恢复中心与失败任务数量',
|
||||
color: const Color(0xFFDC2626),
|
||||
trendValue: widget.controller.alertCount == 0
|
||||
? 0.08
|
||||
: 0.75,
|
||||
),
|
||||
];
|
||||
return cards[index];
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'录制吞吐',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'近 8 小时',
|
||||
style: TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (!hasThroughput)
|
||||
const AppEmptyState(
|
||||
title: '暂无吞吐数据',
|
||||
description: '当前 8 小时窗口内没有可统计的真实录制任务。',
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 140,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: buckets.map((int value) {
|
||||
final ratio = maxBucket == 0
|
||||
? 0.08
|
||||
: value / maxBucket;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'$value',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 18 + (ratio * 90),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'重点直播间',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: const Color(0xFF0F172A),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (widget.controller.focusRooms.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppEmptyState(),
|
||||
)
|
||||
else
|
||||
...widget.controller.focusRooms.map((room) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: RoomCard(
|
||||
room: room,
|
||||
session: widget.controller.activeSessionForRoom(room.id),
|
||||
recentEvent:
|
||||
room.lastAutoStartDecisionSummary ?? '暂无事件',
|
||||
onTap: () {
|
||||
final dependencies = AppScope.of(context);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => RoomDetailPage(
|
||||
dependencies: dependencies,
|
||||
roomId: room.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/network/api_exception.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({
|
||||
super.key,
|
||||
required this.sessionController,
|
||||
});
|
||||
|
||||
final AppSessionController sessionController;
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
bool _submitting = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.sessionController.login(
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
} on ApiException catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth >= 700 ? 420 : 480,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
const _BrandHeader(),
|
||||
const SizedBox(height: 16),
|
||||
_buildForm(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'登录',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户名',
|
||||
prefixIcon: Icon(Icons.person_outline_rounded),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: Icon(Icons.lock_outline_rounded),
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
if (_errorMessage != null) ...<Widget>[
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFDC2626),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandHeader extends StatelessWidget {
|
||||
const _BrandHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Text(
|
||||
'LiveRecorder',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2563EB),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
|
||||
|
||||
class LogsPage extends StatefulWidget {
|
||||
const LogsPage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final LogsController controller;
|
||||
|
||||
@override
|
||||
State<LogsPage> createState() => _LogsPageState();
|
||||
}
|
||||
|
||||
class _LogsPageState extends State<LogsPage> {
|
||||
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
widget.controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _setLevelAndRefresh(int? level) async {
|
||||
widget.controller.setLevel(level);
|
||||
await widget.controller.refresh();
|
||||
}
|
||||
|
||||
Future<void> _setQueryAndRefresh(String query) async {
|
||||
widget.controller.setQuery(query);
|
||||
await widget.controller.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('操作日志'),
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: <Widget>[
|
||||
AppSearchBar(
|
||||
controller: _searchController,
|
||||
hintText: '搜索日志内容 / 分类',
|
||||
onSubmitted: (String value) => _setQueryAndRefresh(value),
|
||||
onChanged: widget.controller.setQuery,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
FilterChip(
|
||||
selected: widget.controller.level == null,
|
||||
onSelected: (_) => _setLevelAndRefresh(null),
|
||||
label: const Text('全部'),
|
||||
),
|
||||
FilterChip(
|
||||
selected: widget.controller.level == 1,
|
||||
onSelected: (_) => _setLevelAndRefresh(1),
|
||||
label: const Text('信息'),
|
||||
),
|
||||
FilterChip(
|
||||
selected: widget.controller.level == 2,
|
||||
onSelected: (_) => _setLevelAndRefresh(2),
|
||||
label: const Text('警告'),
|
||||
),
|
||||
FilterChip(
|
||||
selected: widget.controller.level == 3,
|
||||
onSelected: (_) => _setLevelAndRefresh(3),
|
||||
label: const Text('错误'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const SkeletonCard(height: 180)
|
||||
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
|
||||
AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh();
|
||||
},
|
||||
)
|
||||
else if (widget.controller.logs.isEmpty)
|
||||
const AppEmptyState(
|
||||
title: '暂无日志',
|
||||
description: '当前筛选条件下没有可展示的真实日志。',
|
||||
)
|
||||
else
|
||||
...widget.controller.logs.map((SystemLog log) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
log.message,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
_LogLevelBadge(level: log.level),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
_InfoChip(label: '分类', value: log.category),
|
||||
_InfoChip(label: '时间', value: formatDateTime(log.createdAt)),
|
||||
if ((log.liveRoomId ?? '').isNotEmpty) _InfoChip(label: '房间', value: log.liveRoomId!),
|
||||
if ((log.recordTaskId ?? '').isNotEmpty) _InfoChip(label: '任务', value: log.recordTaskId!),
|
||||
],
|
||||
),
|
||||
if ((log.detail ?? '').trim().isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
log.detail!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogLevelBadge extends StatelessWidget {
|
||||
const _LogLevelBadge({required this.level});
|
||||
|
||||
final int level;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = switch (level) {
|
||||
3 => const Color(0xFFDC2626),
|
||||
2 => const Color(0xFFF59E0B),
|
||||
_ => const Color(0xFF2563EB),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
logLevelLabel(level),
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoChip extends StatelessWidget {
|
||||
const _InfoChip({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$label · $value',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF475569),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MediaBrowserPage extends StatefulWidget {
|
||||
const MediaBrowserPage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final MediaBrowserController controller;
|
||||
|
||||
@override
|
||||
State<MediaBrowserPage> createState() => _MediaBrowserPageState();
|
||||
}
|
||||
|
||||
class _MediaBrowserPageState extends State<MediaBrowserPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh(path: '');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openFile(String relativePath, {bool download = false}) async {
|
||||
final uri = widget.controller.fileUri(relativePath, download: download);
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _transcodeFile(String relativePath) async {
|
||||
try {
|
||||
final message = await widget.controller.transcodeFile(relativePath);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('文件浏览')),
|
||||
body: ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
final response = widget.controller.response;
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(path: widget.controller.currentPath),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
if (response != null && response.breadcrumbs.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: response.breadcrumbs.map((MediaBrowserBreadcrumb crumb) {
|
||||
return ActionChip(
|
||||
label: Text(crumb.label),
|
||||
onPressed: () => widget.controller.refresh(path: crumb.relativePath),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
),
|
||||
),
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const SkeletonCard(height: 220)
|
||||
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
|
||||
AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh(path: widget.controller.currentPath);
|
||||
},
|
||||
)
|
||||
else if (response == null || response.items.isEmpty)
|
||||
const AppEmptyState(
|
||||
title: '目录为空',
|
||||
description: '当前路径下没有可展示的真实文件或目录。',
|
||||
)
|
||||
else
|
||||
...response.items.map((MediaBrowserItem item) {
|
||||
final type = item.type.toLowerCase();
|
||||
final isDirectory = type == 'directory' || type == 'dir' || type == 'folder';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: AppCard(
|
||||
onTap: isDirectory ? () => widget.controller.refresh(path: item.relativePath) : null,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: isDirectory ? const Color(0xFFEFF6FF) : const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
isDirectory ? Icons.folder_rounded : Icons.insert_drive_file_rounded,
|
||||
color: isDirectory ? const Color(0xFF2563EB) : const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
item.name,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
_FileChip(label: '类型', value: item.type),
|
||||
_FileChip(label: '大小', value: formatBytes(item.sizeBytes)),
|
||||
_FileChip(label: '修改时间', value: formatDateTime(item.modifiedAt)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isDirectory)
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (String value) {
|
||||
switch (value) {
|
||||
case 'preview':
|
||||
_openFile(item.relativePath);
|
||||
return;
|
||||
case 'download':
|
||||
_openFile(item.relativePath, download: true);
|
||||
return;
|
||||
case 'transcode':
|
||||
_transcodeFile(item.relativePath);
|
||||
return;
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
|
||||
if (item.canPreview) const PopupMenuItem(value: 'preview', child: Text('预览')),
|
||||
const PopupMenuItem(value: 'download', child: Text('下载')),
|
||||
if (item.canTranscode) const PopupMenuItem(value: 'transcode', child: Text('提交转码')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileChip extends StatelessWidget {
|
||||
const _FileChip({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$label · $value',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF475569),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/dashboard_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/logs_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/monitor_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/profile_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/recordings_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/rooms_page.dart';
|
||||
|
||||
class MobileShellPage extends StatefulWidget {
|
||||
const MobileShellPage({
|
||||
super.key,
|
||||
required this.dependencies,
|
||||
});
|
||||
|
||||
final AppDependencies dependencies;
|
||||
|
||||
@override
|
||||
State<MobileShellPage> createState() => _MobileShellPageState();
|
||||
}
|
||||
|
||||
class _MobileShellPageState extends State<MobileShellPage> with WidgetsBindingObserver {
|
||||
late final DashboardController _dashboardController = DashboardController(
|
||||
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
|
||||
recordingsRepository: widget.dependencies.recordingsRepository,
|
||||
recoveryRepository: widget.dependencies.recoveryRepository,
|
||||
);
|
||||
late final MonitorController _monitorController = MonitorController(
|
||||
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
|
||||
recordingsRepository: widget.dependencies.recordingsRepository,
|
||||
recoveryRepository: widget.dependencies.recoveryRepository,
|
||||
);
|
||||
late final RoomsController _roomsController = RoomsController(
|
||||
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
|
||||
recordingsRepository: widget.dependencies.recordingsRepository,
|
||||
recoveryRepository: widget.dependencies.recoveryRepository,
|
||||
);
|
||||
late final RecordingsController _recordingsController = RecordingsController(
|
||||
recordingsRepository: widget.dependencies.recordingsRepository,
|
||||
settingsRepository: widget.dependencies.settingsRepository,
|
||||
mediaRepository: widget.dependencies.mediaRepository,
|
||||
);
|
||||
late final ProfileController _profileController = ProfileController(
|
||||
settingsRepository: widget.dependencies.settingsRepository,
|
||||
recoveryRepository: widget.dependencies.recoveryRepository,
|
||||
);
|
||||
|
||||
int _selectedIndex = 0;
|
||||
bool _isForeground = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_syncPolling();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_dashboardController.dispose();
|
||||
_monitorController.dispose();
|
||||
_roomsController.dispose();
|
||||
_recordingsController.dispose();
|
||||
_profileController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_isForeground = state == AppLifecycleState.resumed;
|
||||
_syncPolling();
|
||||
}
|
||||
|
||||
void _syncPolling() {
|
||||
final active = _isForeground;
|
||||
_dashboardController.setActive(active && _selectedIndex == 0);
|
||||
_monitorController.setActive(active && _selectedIndex == 1);
|
||||
_roomsController.setActive(active && _selectedIndex == 2);
|
||||
_recordingsController.setActive(active && _selectedIndex == 3);
|
||||
}
|
||||
|
||||
void _openLogs() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LogsPage(
|
||||
controller: LogsController(
|
||||
logsRepository: widget.dependencies.logsRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sessionController = widget.dependencies.sessionController;
|
||||
final userName = sessionController.user?.displayName.isNotEmpty == true
|
||||
? sessionController.user!.displayName
|
||||
: sessionController.user?.username ?? 'L';
|
||||
final userInitials = userName.isEmpty ? 'L' : userName.characters.first.toUpperCase();
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
child: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: <Widget>[
|
||||
DashboardPage(
|
||||
controller: _dashboardController,
|
||||
userInitials: userInitials,
|
||||
onOpenLogs: _openLogs,
|
||||
onOpenProfile: () => setState(() => _selectedIndex = 4),
|
||||
),
|
||||
MonitorPage(
|
||||
controller: _monitorController,
|
||||
userInitials: userInitials,
|
||||
onOpenLogs: _openLogs,
|
||||
onOpenProfile: () => setState(() => _selectedIndex = 4),
|
||||
),
|
||||
RoomsPage(
|
||||
controller: _roomsController,
|
||||
userInitials: userInitials,
|
||||
onOpenLogs: _openLogs,
|
||||
onOpenProfile: () => setState(() => _selectedIndex = 4),
|
||||
),
|
||||
RecordingsPage(
|
||||
controller: _recordingsController,
|
||||
userInitials: userInitials,
|
||||
onOpenLogs: _openLogs,
|
||||
onOpenProfile: () => setState(() => _selectedIndex = 4),
|
||||
),
|
||||
ProfilePage(
|
||||
controller: _profileController,
|
||||
dependencies: widget.dependencies,
|
||||
userInitials: userInitials,
|
||||
onOpenLogs: _openLogs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (int index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_syncPolling();
|
||||
});
|
||||
},
|
||||
destinations: const <NavigationDestination>[
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_rounded),
|
||||
label: '大盘',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.radar_rounded),
|
||||
label: '监控',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.video_camera_back_rounded),
|
||||
label: '直播间',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.folder_copy_rounded),
|
||||
label: '录像',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_rounded),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_preview_card.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MonitorPage extends StatefulWidget {
|
||||
const MonitorPage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.userInitials,
|
||||
required this.onOpenLogs,
|
||||
required this.onOpenProfile,
|
||||
});
|
||||
|
||||
final MonitorController controller;
|
||||
final String userInitials;
|
||||
final VoidCallback onOpenLogs;
|
||||
final VoidCallback onOpenProfile;
|
||||
|
||||
@override
|
||||
State<MonitorPage> createState() => _MonitorPageState();
|
||||
}
|
||||
|
||||
class _MonitorPageState extends State<MonitorPage> {
|
||||
bool _paused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showAddRoomDialog() async {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('添加监控'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '直播间链接 / Room ID',
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
if (controller.text.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
final messenger = ScaffoldMessenger.of(this.context);
|
||||
try {
|
||||
final message = await widget.controller.createRoom(
|
||||
url: controller.text.trim(),
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
} catch (error) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(error.toString())),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('添加'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openLiveRoom(LiveRoom room) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final uri = resolveLiveRoomWatchUri(room);
|
||||
if (uri == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
);
|
||||
if (!launched && mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: <Widget>[
|
||||
MobileHeader(
|
||||
eyebrow: '直播预览 · 自动刷新',
|
||||
title: '实时监控墙',
|
||||
userInitials: widget.userInitials,
|
||||
onNotificationsPressed: widget.onOpenLogs,
|
||||
onProfilePressed: widget.onOpenProfile,
|
||||
trailing: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: <Widget>[
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _showAddRoomDialog,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('添加监控'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_paused = !_paused;
|
||||
widget.controller.setActive(!_paused);
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
_paused
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.pause_rounded,
|
||||
),
|
||||
label: Text(_paused ? '恢复刷新' : '暂停刷新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SkeletonCard(height: 280),
|
||||
)
|
||||
else if (widget.controller.errorMessage != null &&
|
||||
!widget.controller.hasData)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh();
|
||||
},
|
||||
),
|
||||
)
|
||||
else if (widget.controller.filteredRooms.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppEmptyState(),
|
||||
)
|
||||
else
|
||||
LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final isTablet = constraints.maxWidth >= 900;
|
||||
final rooms = widget.controller.filteredRooms;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rooms.length,
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isTablet ? 2 : 1,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: isTablet ? 0.96 : 0.82,
|
||||
),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final room = rooms[index];
|
||||
return RoomPreviewCard(
|
||||
room: room,
|
||||
session: widget.controller.sessionForRoom(room.id),
|
||||
recentEvent: widget.controller.recentEventForRoom(room),
|
||||
onWatchLive: () => _openLiveRoom(room),
|
||||
onTap: () {
|
||||
final dependencies = AppScope.of(context);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => RoomDetailPage(
|
||||
dependencies: dependencies,
|
||||
roomId: room.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/network/api_exception.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
|
||||
|
||||
class NotificationSettingsPage extends StatefulWidget {
|
||||
const NotificationSettingsPage({
|
||||
super.key,
|
||||
required this.settingsRepository,
|
||||
required this.initialSettings,
|
||||
});
|
||||
|
||||
final SettingsRepository settingsRepository;
|
||||
final SystemSettings? initialSettings;
|
||||
|
||||
@override
|
||||
State<NotificationSettingsPage> createState() => _NotificationSettingsPageState();
|
||||
}
|
||||
|
||||
class _NotificationSettingsPageState extends State<NotificationSettingsPage> {
|
||||
final TextEditingController _emailToController = TextEditingController();
|
||||
final TextEditingController _webhookUrlController = TextEditingController();
|
||||
final TextEditingController _webhookTimeoutController = TextEditingController();
|
||||
|
||||
SystemSettings? _settings;
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
String? _errorMessage;
|
||||
bool _enableEmailNotification = false;
|
||||
bool _notifyOnLiveStarted = false;
|
||||
bool _notifyOnException = false;
|
||||
bool _enableWebhookNotification = false;
|
||||
bool _notifyWebhookOnLiveStarted = false;
|
||||
bool _notifyWebhookOnException = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialSettings != null) {
|
||||
_applySettings(widget.initialSettings!);
|
||||
_loading = false;
|
||||
} else {
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailToController.dispose();
|
||||
_webhookUrlController.dispose();
|
||||
_webhookTimeoutController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
try {
|
||||
final settings = await widget.settingsRepository.getSettings();
|
||||
_applySettings(settings);
|
||||
} on ApiException catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _applySettings(SystemSettings settings) {
|
||||
_settings = settings.copy();
|
||||
_enableEmailNotification = settings.enableEmailNotification;
|
||||
_emailToController.text = settings.emailToAddresses;
|
||||
_notifyOnLiveStarted = settings.notifyOnLiveStarted;
|
||||
_notifyOnException = settings.notifyOnException;
|
||||
_enableWebhookNotification = settings.enableWebhookNotification;
|
||||
_webhookUrlController.text = settings.webhookUrl;
|
||||
_webhookTimeoutController.text = '${settings.webhookTimeoutSeconds}';
|
||||
_notifyWebhookOnLiveStarted = settings.notifyWebhookOnLiveStarted;
|
||||
_notifyWebhookOnException = settings.notifyWebhookOnException;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final settings = _settings?.copy();
|
||||
if (settings == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
settings.updateNotificationSettings(
|
||||
enableEmailNotification: _enableEmailNotification,
|
||||
emailToAddresses: _emailToController.text.trim(),
|
||||
notifyOnLiveStarted: _notifyOnLiveStarted,
|
||||
notifyOnException: _notifyOnException,
|
||||
enableWebhookNotification: _enableWebhookNotification,
|
||||
webhookUrl: _webhookUrlController.text.trim(),
|
||||
webhookTimeoutSeconds: int.tryParse(_webhookTimeoutController.text.trim()) ?? 0,
|
||||
notifyWebhookOnLiveStarted: _notifyWebhookOnLiveStarted,
|
||||
notifyWebhookOnException: _notifyWebhookOnException,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final saved = await widget.settingsRepository.updateSettings(settings);
|
||||
_applySettings(saved);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('通知设置已保存。')),
|
||||
);
|
||||
} on ApiException catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('通知设置')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
if (_loading)
|
||||
const SkeletonCard(height: 220)
|
||||
else if (_errorMessage != null && _settings == null)
|
||||
AppErrorCard(
|
||||
message: _errorMessage!,
|
||||
onRetry: () {
|
||||
_load();
|
||||
},
|
||||
)
|
||||
else ...<Widget>[
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'邮件通知',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _enableEmailNotification,
|
||||
onChanged: (bool value) => setState(() => _enableEmailNotification = value),
|
||||
title: const Text('启用邮件通知'),
|
||||
),
|
||||
TextField(
|
||||
controller: _emailToController,
|
||||
decoration: const InputDecoration(labelText: '收件人地址(逗号分隔)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _notifyOnLiveStarted,
|
||||
onChanged: (bool value) => setState(() => _notifyOnLiveStarted = value),
|
||||
title: const Text('开播时通知'),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _notifyOnException,
|
||||
onChanged: (bool value) => setState(() => _notifyOnException = value),
|
||||
title: const Text('异常时通知'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'Webhook 通知',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _enableWebhookNotification,
|
||||
onChanged: (bool value) => setState(() => _enableWebhookNotification = value),
|
||||
title: const Text('启用 Webhook 通知'),
|
||||
),
|
||||
TextField(
|
||||
controller: _webhookUrlController,
|
||||
decoration: const InputDecoration(labelText: 'Webhook URL'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _webhookTimeoutController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '超时时间(秒)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _notifyWebhookOnLiveStarted,
|
||||
onChanged: (bool value) => setState(() => _notifyWebhookOnLiveStarted = value),
|
||||
title: const Text('开播时回调'),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _notifyWebhookOnException,
|
||||
onChanged: (bool value) => setState(() => _notifyWebhookOnException = value),
|
||||
title: const Text('异常时回调'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Color(0xFFDC2626), height: 1.5),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('保存设置'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_settings_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/logs_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/media_browser_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/notification_settings_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/security_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/storage_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/system_summary_page.dart';
|
||||
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.dependencies,
|
||||
required this.userInitials,
|
||||
required this.onOpenLogs,
|
||||
});
|
||||
|
||||
final ProfileController controller;
|
||||
final AppDependencies dependencies;
|
||||
final String userInitials;
|
||||
final VoidCallback onOpenLogs;
|
||||
|
||||
@override
|
||||
State<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _push(Widget page) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => page),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = widget.dependencies.sessionController.user;
|
||||
final displayName = user?.displayName.isNotEmpty == true ? user!.displayName : user?.username ?? '--';
|
||||
final backendConfig = AppScope.backendConfigOf(context);
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: <Widget>[
|
||||
MobileHeader(
|
||||
eyebrow: '个人中心',
|
||||
title: '我的',
|
||||
userInitials: widget.userInitials,
|
||||
onNotificationsPressed: widget.onOpenLogs,
|
||||
onProfilePressed: () {},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppCard(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: const Color(0xFFE0ECFF),
|
||||
foregroundColor: const Color(0xFF2563EB),
|
||||
child: Text(
|
||||
widget.userInitials,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
displayName,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
user?.username ?? '--',
|
||||
style: const TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
const _MetaChip(label: '角色 --'),
|
||||
const _MetaChip(label: '环境 --'),
|
||||
_MetaChip(label: '到期 ${formatDateTime(user?.expiresAt)}'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
SkeletonCard(height: 120),
|
||||
SizedBox(height: 12),
|
||||
SkeletonCard(height: 120),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh();
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppCard(
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: <Widget>[
|
||||
_MetaChip(label: '输出目录 ${valueOrDash(widget.controller.settings?.outputRoot)}'),
|
||||
_MetaChip(label: '轮询 ${widget.controller.settings?.pollingIntervalSeconds ?? '--'} 秒'),
|
||||
_MetaChip(
|
||||
label:
|
||||
'自动开录 ${widget.controller.settings?.autoStartRecordingOnLive == true ? '开启' : '关闭'}',
|
||||
),
|
||||
_MetaChip(
|
||||
label: '存储守护 ${widget.controller.settings?.enableStorageGuard == true ? '开启' : '关闭'}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_EntryTile(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
title: '账号安全',
|
||||
subtitle: '修改当前账号密码',
|
||||
onTap: () => _push(
|
||||
SecurityPage(
|
||||
sessionController: widget.dependencies.sessionController,
|
||||
),
|
||||
),
|
||||
),
|
||||
_EntryTile(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: '连接设置',
|
||||
subtitle: '修改后端地址并切换当前环境',
|
||||
onTap: () => _push(
|
||||
BackendSettingsPage(
|
||||
bootstrapController: backendConfig,
|
||||
),
|
||||
),
|
||||
),
|
||||
_EntryTile(
|
||||
icon: Icons.notifications_outlined,
|
||||
title: '通知设置',
|
||||
subtitle: '邮件和 Webhook 通知开关',
|
||||
onTap: () => _push(
|
||||
NotificationSettingsPage(
|
||||
settingsRepository: widget.dependencies.settingsRepository,
|
||||
initialSettings: widget.controller.settings,
|
||||
),
|
||||
),
|
||||
),
|
||||
_EntryTile(
|
||||
icon: Icons.storage_rounded,
|
||||
title: '存储管理',
|
||||
subtitle: '查看存储守护和保留清理状态',
|
||||
onTap: () => _push(
|
||||
StoragePage(
|
||||
controller: StorageController(
|
||||
settingsRepository: widget.dependencies.settingsRepository,
|
||||
recoveryRepository: widget.dependencies.recoveryRepository,
|
||||
),
|
||||
dependencies: widget.dependencies,
|
||||
),
|
||||
),
|
||||
),
|
||||
_EntryTile(
|
||||
icon: Icons.receipt_long_rounded,
|
||||
title: '操作日志',
|
||||
subtitle: '真实系统日志筛选与查看',
|
||||
onTap: () => _push(
|
||||
LogsPage(
|
||||
controller: LogsController(
|
||||
logsRepository: widget.dependencies.logsRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_EntryTile(
|
||||
icon: Icons.settings_outlined,
|
||||
title: '系统设置',
|
||||
subtitle: '当前系统配置摘要',
|
||||
onTap: () => _push(
|
||||
SystemSummaryPage(
|
||||
settingsRepository: widget.dependencies.settingsRepository,
|
||||
initialSettings: widget.controller.settings,
|
||||
),
|
||||
),
|
||||
),
|
||||
_EntryTile(
|
||||
icon: Icons.folder_outlined,
|
||||
title: '文件浏览',
|
||||
subtitle: '通过真实 /api/media 接口浏览录制目录',
|
||||
onTap: () => _push(
|
||||
MediaBrowserPage(
|
||||
controller: MediaBrowserController(
|
||||
mediaRepository: widget.dependencies.mediaRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () async => widget.dependencies.sessionController.logout(),
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
label: const Text('退出登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EntryTile extends StatelessWidget {
|
||||
const _EntryTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: AppCard(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(icon, color: const Color(0xFF2563EB)),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, color: Color(0xFF94A3B8)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaChip extends StatelessWidget {
|
||||
const _MetaChip({
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF475569),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class RecordingDetailPage extends StatefulWidget {
|
||||
const RecordingDetailPage({
|
||||
super.key,
|
||||
required this.dependencies,
|
||||
required this.taskId,
|
||||
});
|
||||
|
||||
final AppDependencies dependencies;
|
||||
final String taskId;
|
||||
|
||||
@override
|
||||
State<RecordingDetailPage> createState() => _RecordingDetailPageState();
|
||||
}
|
||||
|
||||
class _RecordingDetailPageState extends State<RecordingDetailPage> {
|
||||
late final RecordingDetailController _controller = RecordingDetailController(
|
||||
recordingsRepository: widget.dependencies.recordingsRepository,
|
||||
settingsRepository: widget.dependencies.settingsRepository,
|
||||
mediaRepository: widget.dependencies.mediaRepository,
|
||||
taskId: widget.taskId,
|
||||
);
|
||||
bool _previewLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openPreview() async {
|
||||
setState(() {
|
||||
_previewLoading = true;
|
||||
});
|
||||
try {
|
||||
final ticket = await _controller.createPreviewTicket();
|
||||
if (ticket.url.isEmpty) {
|
||||
throw Exception('预览地址为空。');
|
||||
}
|
||||
await launchUrl(Uri.parse(ticket.url), mode: LaunchMode.externalApplication);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_previewLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openDownload() async {
|
||||
final uri = _controller.downloadUri;
|
||||
if (uri == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('当前任务暂无可下载文件。')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('录像详情')),
|
||||
body: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (BuildContext context, _) {
|
||||
final detail = _controller.detail;
|
||||
final task = detail?.task;
|
||||
final result = detail?.result;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
if (_controller.isLoading && !_controller.hasData)
|
||||
const SkeletonCard(height: 240)
|
||||
else if (_controller.errorMessage != null && !_controller.hasData)
|
||||
AppErrorCard(
|
||||
message: _controller.errorMessage!,
|
||||
onRetry: () {
|
||||
_controller.refresh();
|
||||
},
|
||||
)
|
||||
else if (detail == null || task == null)
|
||||
const AppEmptyState(
|
||||
title: '暂无录像详情',
|
||||
description: '当前任务没有返回可展示的真实详情。',
|
||||
)
|
||||
else ...<Widget>[
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
(task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last.isEmpty
|
||||
? '--'
|
||||
: (task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
StatusBadge(
|
||||
status: task.status,
|
||||
context: 'task',
|
||||
label: taskStatusLabel(task.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
_DetailChip(label: '直播间', value: task.liveRoomTitle.isEmpty ? '--' : task.liveRoomTitle),
|
||||
_DetailChip(label: 'Room ID', value: task.roomId.isEmpty ? '--' : task.roomId),
|
||||
_DetailChip(label: '清晰度', value: qualityLabel(task.preferredQuality)),
|
||||
_DetailChip(label: '输出格式', value: outputFormatLabel(task.outputFormat)),
|
||||
_DetailChip(label: '创建时间', value: formatDateTime(task.createdAt)),
|
||||
_DetailChip(label: '录制时长', value: formatDurationSeconds(result?.durationSeconds ?? task.durationSeconds)),
|
||||
],
|
||||
),
|
||||
if ((task.errorMessage ?? '').trim().isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
task.errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFDC2626),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: <Widget>[
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _previewLoading ? null : _openPreview,
|
||||
icon: const Icon(Icons.play_circle_outline_rounded),
|
||||
label: Text(_previewLoading ? '打开中...' : '预览'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _openDownload,
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
label: const Text('下载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'结果信息',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (result == null)
|
||||
const AppEmptyState(
|
||||
title: '暂无结果',
|
||||
description: '任务仍在处理中,或后端尚未返回结果对象。',
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_DetailRow(label: '文件路径', value: result.filePath.isEmpty ? '--' : result.filePath),
|
||||
_DetailRow(label: '文件大小', value: formatBytes(result.fileSizeBytes)),
|
||||
_DetailRow(label: '时长', value: formatDurationSeconds(result.durationSeconds)),
|
||||
_DetailRow(label: '最终状态', value: taskStatusLabel(result.finalStatus)),
|
||||
_DetailRow(label: '上传状态', value: uploadStatusLabel(result.uploadStatus)),
|
||||
_DetailRow(label: '最近上传时间', value: formatDateTime(result.lastUploadedAt)),
|
||||
_DetailRow(label: '远端视频路径', value: valueOrDash(result.remoteVideoPath)),
|
||||
if ((result.errorMessage ?? '').trim().isNotEmpty)
|
||||
_DetailRow(label: '错误信息', value: result.errorMessage!),
|
||||
if ((result.uploadErrorMessage ?? '').trim().isNotEmpty)
|
||||
_DetailRow(label: '上传错误', value: result.uploadErrorMessage!),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'任务日志',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (detail.logs.isEmpty)
|
||||
const AppEmptyState(
|
||||
title: '暂无日志',
|
||||
description: '当前任务没有返回附带日志。',
|
||||
)
|
||||
else
|
||||
...detail.logs.map((SystemLog log) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
log.message,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDateTime(log.createdAt),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF94A3B8),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
log.detail?.trim().isEmpty ?? true ? log.category : '${log.category} · ${log.detail}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const Divider(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailChip extends StatelessWidget {
|
||||
const _DetailChip({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$label · $value',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF475569),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/recording_detail_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/recording_file_card.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class RecordingsPage extends StatefulWidget {
|
||||
const RecordingsPage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.userInitials,
|
||||
required this.onOpenLogs,
|
||||
required this.onOpenProfile,
|
||||
});
|
||||
|
||||
final RecordingsController controller;
|
||||
final String userInitials;
|
||||
final VoidCallback onOpenLogs;
|
||||
final VoidCallback onOpenProfile;
|
||||
|
||||
@override
|
||||
State<RecordingsPage> createState() => _RecordingsPageState();
|
||||
}
|
||||
|
||||
class _RecordingsPageState extends State<RecordingsPage> {
|
||||
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openDownload(RecordTask task) async {
|
||||
final uri = widget.controller.downloadUriForTask(task);
|
||||
if (uri == null) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('当前录像文件无法映射到下载接口。')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
final tasks = widget.controller.filteredTasks;
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: <Widget>[
|
||||
MobileHeader(
|
||||
eyebrow: '转码 · 归档 · 下载',
|
||||
title: '录像文件',
|
||||
userInitials: widget.userInitials,
|
||||
onNotificationsPressed: widget.onOpenLogs,
|
||||
onProfilePressed: widget.onOpenProfile,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppSearchBar(
|
||||
controller: _searchController,
|
||||
hintText: '搜索文件名 / 直播间',
|
||||
onChanged: widget.controller.setQuery,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SkeletonCard(height: 160),
|
||||
)
|
||||
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh();
|
||||
},
|
||||
),
|
||||
)
|
||||
else if (tasks.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppEmptyState(),
|
||||
)
|
||||
else
|
||||
...tasks.map((RecordTask task) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: RecordingFileCard(
|
||||
task: task,
|
||||
detail: widget.controller.cachedDetail(task.id),
|
||||
onVisible: () => widget.controller.ensureDetailLoaded(task.id),
|
||||
onTap: () {
|
||||
final dependencies = AppScope.of(context);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => RecordingDetailPage(
|
||||
dependencies: dependencies,
|
||||
taskId: task.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onDownload: () => _openDownload(task),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class RoomDetailPage extends StatefulWidget {
|
||||
const RoomDetailPage({
|
||||
super.key,
|
||||
required this.dependencies,
|
||||
required this.roomId,
|
||||
});
|
||||
|
||||
final AppDependencies dependencies;
|
||||
final String roomId;
|
||||
|
||||
@override
|
||||
State<RoomDetailPage> createState() => _RoomDetailPageState();
|
||||
}
|
||||
|
||||
class _RoomDetailPageState extends State<RoomDetailPage> {
|
||||
late final RoomDetailController _controller = RoomDetailController(
|
||||
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
|
||||
recordingsRepository: widget.dependencies.recordingsRepository,
|
||||
recoveryRepository: widget.dependencies.recoveryRepository,
|
||||
roomId: widget.roomId,
|
||||
);
|
||||
bool _actionBusy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
RecordSession? get _activeSession {
|
||||
final sessions = _controller.sessions.where((RecordSession session) => isTaskActive(session.status)).toList(growable: false);
|
||||
if (sessions.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
sessions.sort((RecordSession a, RecordSession b) => (b.startedAt ?? b.createdAt).compareTo(a.startedAt ?? a.createdAt));
|
||||
return sessions.first;
|
||||
}
|
||||
|
||||
Future<void> _runAction(Future<String> Function() action) async {
|
||||
setState(() {
|
||||
_actionBusy = true;
|
||||
});
|
||||
try {
|
||||
final message = await action();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
await _controller.refresh(silent: true);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_actionBusy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openLiveRoom(LiveRoom room) async {
|
||||
final uri = resolveLiveRoomWatchUri(room);
|
||||
if (uri == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showStartRecordingSheet(LiveRoom room) async {
|
||||
final qualityController = TextEditingController(text: room.effectiveSettings.preferredQuality);
|
||||
final outputFormat = ValueNotifier<int>(room.effectiveSettings.outputFormat);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'开始录制',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: qualityController,
|
||||
decoration: const InputDecoration(labelText: '清晰度'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: outputFormat,
|
||||
builder: (BuildContext context, int value, _) {
|
||||
return DropdownButtonFormField<int>(
|
||||
initialValue: value,
|
||||
decoration: const InputDecoration(labelText: '输出格式'),
|
||||
items: const <DropdownMenuItem<int>>[
|
||||
DropdownMenuItem(value: 0, child: Text('MP4')),
|
||||
DropdownMenuItem(value: 1, child: Text('TS')),
|
||||
],
|
||||
onChanged: (int? next) {
|
||||
if (next != null) {
|
||||
outputFormat.value = next;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _runAction(() async {
|
||||
await widget.dependencies.recordingsRepository.startRecording(
|
||||
liveRoomId: room.id,
|
||||
preferredQuality: qualityController.text.trim().isEmpty
|
||||
? room.effectiveSettings.preferredQuality
|
||||
: qualityController.text.trim(),
|
||||
outputFormat: outputFormat.value,
|
||||
);
|
||||
return '录制任务已启动';
|
||||
});
|
||||
},
|
||||
child: const Text('启动录制'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
qualityController.dispose();
|
||||
outputFormat.dispose();
|
||||
}
|
||||
|
||||
Future<void> _showEditSheet(LiveRoom room) async {
|
||||
final remarkController = TextEditingController(text: room.remark ?? '');
|
||||
final aliasController = TextEditingController(text: room.alias ?? '');
|
||||
final pollingController = TextEditingController(text: room.pollingIntervalSecondsOverride?.toString() ?? '');
|
||||
bool isPinned = room.isPinned;
|
||||
bool isPriority = room.isPriority;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, void Function(void Function()) setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'编辑直播间',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: remarkController,
|
||||
decoration: const InputDecoration(labelText: '备注'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: aliasController,
|
||||
decoration: const InputDecoration(labelText: '主播别名'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: pollingController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '单房间轮询间隔(秒)'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: isPinned,
|
||||
onChanged: (bool value) => setSheetState(() => isPinned = value),
|
||||
title: const Text('置顶'),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: isPriority,
|
||||
onChanged: (bool value) => setSheetState(() => isPriority = value),
|
||||
title: const Text('重点主播'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _runAction(() async {
|
||||
await widget.dependencies.liveRoomsRepository.updateMetadata(
|
||||
roomId: room.id,
|
||||
payload: <String, dynamic>{
|
||||
'remark': remarkController.text.trim().isEmpty ? null : remarkController.text.trim(),
|
||||
'isPinned': isPinned,
|
||||
'alias': aliasController.text.trim().isEmpty ? null : aliasController.text.trim(),
|
||||
'isPriority': isPriority,
|
||||
'pollingIntervalSecondsOverride': int.tryParse(pollingController.text.trim()),
|
||||
},
|
||||
);
|
||||
return '房间信息已保存';
|
||||
});
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
remarkController.dispose();
|
||||
aliasController.dispose();
|
||||
pollingController.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('直播间详情')),
|
||||
body: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (BuildContext context, _) {
|
||||
final activeSession = _activeSession;
|
||||
final room = _controller.room;
|
||||
final recoveryInfo = _controller.recoveryInfo;
|
||||
final allTasks = _controller.sessions.expand((RecordSession session) => session.tasks).toList(growable: false);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
if (_controller.isLoading && !_controller.hasData)
|
||||
const SkeletonCard(height: 260)
|
||||
else if (_controller.errorMessage != null && !_controller.hasData)
|
||||
AppErrorCard(
|
||||
message: _controller.errorMessage!,
|
||||
onRetry: () {
|
||||
_controller.refresh();
|
||||
},
|
||||
)
|
||||
else if (room == null)
|
||||
const AppEmptyState(
|
||||
title: '暂无直播间详情',
|
||||
description: '当前房间没有返回可展示的真实详情。',
|
||||
)
|
||||
else ...<Widget>[
|
||||
_RoomHero(room: room),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: <Widget>[
|
||||
if (hasLiveRoomWatchSource(room))
|
||||
FilledButton.icon(
|
||||
onPressed: _actionBusy ? null : () => _openLiveRoom(room),
|
||||
icon: const Icon(Icons.play_circle_fill_rounded),
|
||||
label: const Text('观看直播'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _actionBusy
|
||||
? null
|
||||
: () => _runAction(() async {
|
||||
await widget.dependencies.liveRoomsRepository.refreshRoom(room.id);
|
||||
return '直播状态已刷新';
|
||||
}),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('刷新'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _actionBusy ? null : () => _showStartRecordingSheet(room),
|
||||
icon: const Icon(Icons.fiber_manual_record_rounded),
|
||||
label: const Text('开始录制'),
|
||||
),
|
||||
if (activeSession != null)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _actionBusy
|
||||
? null
|
||||
: () => _runAction(() async {
|
||||
await widget.dependencies.recordingsRepository.stopSession(activeSession.id);
|
||||
return '停止录制请求已提交';
|
||||
}),
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
label: const Text('停止录制'),
|
||||
),
|
||||
if (recoveryInfo != null || (room.lastAutoStartDecisionCode ?? '').isNotEmpty)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _actionBusy
|
||||
? null
|
||||
: () => _runAction(() async {
|
||||
await widget.dependencies.recoveryRepository.retryLiveRoom(room.id);
|
||||
return '重试请求已提交';
|
||||
}),
|
||||
icon: const Icon(Icons.restart_alt_rounded),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _actionBusy ? null : () => _showEditSheet(room),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('编辑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'状态信息',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
StatusBadge(
|
||||
status: room.availabilityStatus,
|
||||
context: 'availability',
|
||||
label: availabilityLabel(room.availabilityStatus),
|
||||
),
|
||||
StatusBadge(
|
||||
status: room.currentRecordingState,
|
||||
context: 'recording',
|
||||
label: recordingStateLabel(room.currentRecordingState),
|
||||
),
|
||||
if (room.isPinned) const StatusBadge(status: 'completed', label: '置顶'),
|
||||
if (room.isPriority) const StatusBadge(status: 'retrying', label: '重点'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DetailRow(label: '平台', value: room.platformName.isEmpty ? '--' : room.platformName),
|
||||
_DetailRow(label: 'Room ID', value: room.roomId.isEmpty ? '--' : room.roomId),
|
||||
_DetailRow(label: '在线人数', value: '--'),
|
||||
_DetailRow(label: '码率', value: '--'),
|
||||
_DetailRow(
|
||||
label: '录制时长',
|
||||
value: () {
|
||||
if (activeSession == null) {
|
||||
return '--';
|
||||
}
|
||||
final startedAt = DateTime.tryParse(activeSession.startedAt ?? activeSession.createdAt)?.toLocal();
|
||||
if (startedAt == null) {
|
||||
return '--';
|
||||
}
|
||||
return formatDurationSeconds(DateTime.now().difference(startedAt).inSeconds);
|
||||
}(),
|
||||
),
|
||||
_DetailRow(label: '采集账号', value: '--'),
|
||||
_DetailRow(
|
||||
label: '最近事件',
|
||||
value: recoveryInfo?.lastAutoStartDecisionSummary ??
|
||||
room.lastAutoStartDecisionSummary ??
|
||||
autoStartDecisionLabel(recoveryInfo?.lastAutoStartDecisionCode ?? room.lastAutoStartDecisionCode),
|
||||
),
|
||||
_DetailRow(label: '最近检查', value: formatDateTime(room.lastCheckedAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'录制策略',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DetailRow(label: '清晰度', value: qualityLabel(room.effectiveSettings.preferredQuality)),
|
||||
_DetailRow(label: '输出格式', value: outputFormatLabel(room.effectiveSettings.outputFormat)),
|
||||
_DetailRow(label: '保存模式', value: saveModeLabel(room.effectiveSettings.saveMode)),
|
||||
_DetailRow(label: '录制模板', value: recordingTemplateLabel(room.effectiveSettings.recordingTemplate)),
|
||||
_DetailRow(label: '分段时长', value: '${room.effectiveSettings.segmentDurationMinutes} 分钟'),
|
||||
_DetailRow(label: '自动重连', value: room.effectiveSettings.enableAutoReconnect ? '开启' : '关闭'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'最近会话与文件',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_controller.sessions.isEmpty)
|
||||
const AppEmptyState(
|
||||
title: '暂无会话',
|
||||
description: '当前直播间还没有可展示的录制会话。',
|
||||
)
|
||||
else ...<Widget>[
|
||||
..._controller.sessions.take(3).map((RecordSession session) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
'会话 ${session.id}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
StatusBadge(
|
||||
status: session.status,
|
||||
context: 'session',
|
||||
label: taskStatusLabel(session.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${formatDateTime(session.startedAt ?? session.createdAt)} · 片段 ${session.segmentCount}',
|
||||
style: const TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
const Divider(height: 18),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (allTasks.isNotEmpty)
|
||||
...allTasks.take(5).map((RecordTask task) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _TaskRow(task: task),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomHero extends StatelessWidget {
|
||||
const _RoomHero({
|
||||
required this.room,
|
||||
});
|
||||
|
||||
final LiveRoom room;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preview = room.coverUrl;
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: <Widget>[
|
||||
if (preview != null && preview.isNotEmpty)
|
||||
Image.network(
|
||||
preview,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) => const _FallbackHero(),
|
||||
)
|
||||
else
|
||||
const _FallbackHero(),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[
|
||||
Colors.black.withValues(alpha: 0.15),
|
||||
Colors.black.withValues(alpha: 0.60),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: 16,
|
||||
child: StatusBadge(
|
||||
status: room.availabilityStatus,
|
||||
context: 'availability',
|
||||
label: availabilityLabel(room.availabilityStatus),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
room.title ?? room.anchorName ?? room.roomId,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${room.platformName.isEmpty ? '--' : room.platformName} · Room ${room.roomId.isEmpty ? '--' : room.roomId}',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackHero extends StatelessWidget {
|
||||
const _FallbackHero();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[Color(0xFF0F172A), Color(0xFF1E293B)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'LiveRecorder',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
});
|
||||
|
||||
final RecordTask task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fileName = (task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
fileName.isEmpty ? '--' : fileName,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${formatDateTime(task.createdAt)} · ${formatDurationSeconds(task.durationSeconds)}',
|
||||
style: const TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusBadge(
|
||||
status: task.status,
|
||||
context: 'task',
|
||||
label: taskStatusLabel(task.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_card.dart';
|
||||
|
||||
class RoomsPage extends StatefulWidget {
|
||||
const RoomsPage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.userInitials,
|
||||
required this.onOpenLogs,
|
||||
required this.onOpenProfile,
|
||||
});
|
||||
|
||||
final RoomsController controller;
|
||||
final String userInitials;
|
||||
final VoidCallback onOpenLogs;
|
||||
final VoidCallback onOpenProfile;
|
||||
|
||||
@override
|
||||
State<RoomsPage> createState() => _RoomsPageState();
|
||||
}
|
||||
|
||||
class _RoomsPageState extends State<RoomsPage> {
|
||||
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _showAddRoomDialog() async {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('新增直播间'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '直播间链接 / Room ID',
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
if (controller.text.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _runAction(() => widget.controller.createRoom(url: controller.text.trim()));
|
||||
},
|
||||
child: const Text('添加'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> _runAction(Future<String> Function() action) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final message = await action();
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
} catch (error) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(error.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showStartRecordingSheet(LiveRoom room) async {
|
||||
final qualityController = TextEditingController(text: room.effectiveSettings.preferredQuality);
|
||||
final outputFormat = ValueNotifier<int>(room.effectiveSettings.outputFormat);
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'开始录制',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: qualityController,
|
||||
decoration: const InputDecoration(labelText: '清晰度'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: outputFormat,
|
||||
builder: (BuildContext context, int value, _) {
|
||||
return DropdownButtonFormField<int>(
|
||||
initialValue: value,
|
||||
decoration: const InputDecoration(labelText: '输出格式'),
|
||||
items: const <DropdownMenuItem<int>>[
|
||||
DropdownMenuItem(value: 0, child: Text('MP4')),
|
||||
DropdownMenuItem(value: 1, child: Text('TS')),
|
||||
],
|
||||
onChanged: (int? next) {
|
||||
if (next != null) {
|
||||
outputFormat.value = next;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _runAction(
|
||||
() => widget.controller.startRecording(
|
||||
room: room,
|
||||
preferredQuality: qualityController.text.trim(),
|
||||
outputFormat: outputFormat.value,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('启动录制'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
qualityController.dispose();
|
||||
outputFormat.dispose();
|
||||
}
|
||||
|
||||
Future<void> _showEditSheet(LiveRoom room) async {
|
||||
final remarkController = TextEditingController(text: room.remark ?? '');
|
||||
final aliasController = TextEditingController(text: room.alias ?? '');
|
||||
final pollingController = TextEditingController(text: room.pollingIntervalSecondsOverride?.toString() ?? '');
|
||||
final qualityController = TextEditingController(text: room.overrides.preferredQuality ?? room.effectiveSettings.preferredQuality);
|
||||
final segmentController = TextEditingController(
|
||||
text: (room.overrides.segmentDurationMinutes ?? room.effectiveSettings.segmentDurationMinutes).toString(),
|
||||
);
|
||||
bool isPinned = room.isPinned;
|
||||
bool isPriority = room.isPriority;
|
||||
int outputFormat = room.overrides.outputFormat ?? room.effectiveSettings.outputFormat;
|
||||
int saveMode = room.overrides.saveMode ?? room.effectiveSettings.saveMode;
|
||||
int recordingTemplate = room.overrides.recordingTemplate ?? room.effectiveSettings.recordingTemplate;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, void Function(void Function()) setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'编辑直播间',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: remarkController,
|
||||
decoration: const InputDecoration(labelText: '备注'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: aliasController,
|
||||
decoration: const InputDecoration(labelText: '主播别名'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: pollingController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '单房间轮询间隔(秒)'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SwitchListTile(
|
||||
value: isPinned,
|
||||
onChanged: (bool value) => setSheetState(() => isPinned = value),
|
||||
title: const Text('置顶'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: isPriority,
|
||||
onChanged: (bool value) => setSheetState(() => isPriority = value),
|
||||
title: const Text('重点主播'),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
const Text(
|
||||
'录制设置',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: qualityController,
|
||||
decoration: const InputDecoration(labelText: '默认清晰度'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: outputFormat,
|
||||
decoration: const InputDecoration(labelText: '默认输出格式'),
|
||||
items: const <DropdownMenuItem<int>>[
|
||||
DropdownMenuItem(value: 0, child: Text('MP4')),
|
||||
DropdownMenuItem(value: 1, child: Text('TS')),
|
||||
],
|
||||
onChanged: (int? value) => setSheetState(() => outputFormat = value ?? outputFormat),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: saveMode,
|
||||
decoration: const InputDecoration(labelText: '保存模式'),
|
||||
items: const <DropdownMenuItem<int>>[
|
||||
DropdownMenuItem(value: 0, child: Text('单文件')),
|
||||
DropdownMenuItem(value: 1, child: Text('分段')),
|
||||
],
|
||||
onChanged: (int? value) => setSheetState(() => saveMode = value ?? saveMode),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: recordingTemplate,
|
||||
decoration: const InputDecoration(labelText: '录制模板'),
|
||||
items: const <DropdownMenuItem<int>>[
|
||||
DropdownMenuItem(value: 0, child: Text('直接封装')),
|
||||
DropdownMenuItem(value: 1, child: Text('均衡 MP4')),
|
||||
DropdownMenuItem(value: 2, child: Text('归档 TS')),
|
||||
],
|
||||
onChanged: (int? value) => setSheetState(() => recordingTemplate = value ?? recordingTemplate),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: segmentController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '分段时长(分钟)'),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
final pollingValue = int.tryParse(pollingController.text.trim());
|
||||
final segmentValue = int.tryParse(segmentController.text.trim());
|
||||
await _runAction(
|
||||
() => widget.controller.saveMetadata(
|
||||
room: room,
|
||||
remark: remarkController.text,
|
||||
isPinned: isPinned,
|
||||
alias: aliasController.text,
|
||||
isPriority: isPriority,
|
||||
pollingIntervalSecondsOverride: pollingValue,
|
||||
),
|
||||
);
|
||||
await _runAction(
|
||||
() => widget.controller.saveRoomSettings(
|
||||
room: room,
|
||||
payload: <String, dynamic>{
|
||||
'preferredQualityOverride': qualityController.text.trim(),
|
||||
'outputFormatOverride': outputFormat,
|
||||
'saveModeOverride': saveMode,
|
||||
'recordingTemplateOverride': recordingTemplate,
|
||||
'segmentDurationMinutesOverride': segmentValue,
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
remarkController.dispose();
|
||||
aliasController.dispose();
|
||||
pollingController.dispose();
|
||||
qualityController.dispose();
|
||||
segmentController.dispose();
|
||||
}
|
||||
|
||||
PopupMenuButton<String> _roomMenu(LiveRoom room) {
|
||||
return PopupMenuButton<String>(
|
||||
onSelected: (String value) {
|
||||
switch (value) {
|
||||
case 'refresh':
|
||||
unawaited(_runAction(() => widget.controller.refreshRoom(room)));
|
||||
return;
|
||||
case 'toggle':
|
||||
unawaited(_runAction(() => widget.controller.toggleRoomEnabled(room)));
|
||||
return;
|
||||
case 'start':
|
||||
unawaited(_showStartRecordingSheet(room));
|
||||
return;
|
||||
case 'retry':
|
||||
unawaited(_runAction(() => widget.controller.retryRoom(room)));
|
||||
return;
|
||||
case 'edit':
|
||||
unawaited(_showEditSheet(room));
|
||||
return;
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
|
||||
const PopupMenuItem(value: 'refresh', child: Text('刷新状态')),
|
||||
PopupMenuItem(value: 'toggle', child: Text(room.isEnabled ? '停用' : '启用')),
|
||||
const PopupMenuItem(value: 'start', child: Text('开始录制')),
|
||||
const PopupMenuItem(value: 'retry', child: Text('重试恢复')),
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
final filteredRooms = widget.controller.filteredRooms;
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
children: <Widget>[
|
||||
MobileHeader(
|
||||
eyebrow: '直播状态 · 录制状态',
|
||||
title: '直播间',
|
||||
userInitials: widget.userInitials,
|
||||
onNotificationsPressed: widget.onOpenLogs,
|
||||
onProfilePressed: widget.onOpenProfile,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppSearchBar(
|
||||
controller: _searchController,
|
||||
hintText: '搜索主播 / Room ID / 状态',
|
||||
onChanged: widget.controller.setQuery,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: RoomFilter.values.map((RoomFilter filter) {
|
||||
final label = switch (filter) {
|
||||
RoomFilter.all => '全部',
|
||||
RoomFilter.live => '直播中',
|
||||
RoomFilter.recording => '录制中',
|
||||
RoomFilter.error => '异常',
|
||||
RoomFilter.retrying => '重试中',
|
||||
};
|
||||
return FilterChip(
|
||||
selected: widget.controller.filter == filter,
|
||||
onSelected: (_) => widget.controller.setFilter(filter),
|
||||
label: Text(label),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _showAddRoomDialog,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('新增直播间'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SkeletonCard(height: 180),
|
||||
)
|
||||
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh();
|
||||
},
|
||||
),
|
||||
)
|
||||
else if (filteredRooms.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppEmptyState(),
|
||||
)
|
||||
else
|
||||
...filteredRooms.map((LiveRoom room) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: RoomCard(
|
||||
room: room,
|
||||
session: widget.controller.sessionForRoom(room.id),
|
||||
recentEvent: widget.controller.recentEventForRoom(room),
|
||||
trailing: _roomMenu(room),
|
||||
onTap: () {
|
||||
final dependencies = AppScope.of(context);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => RoomDetailPage(
|
||||
dependencies: dependencies,
|
||||
roomId: room.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/network/api_exception.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
|
||||
|
||||
class SecurityPage extends StatefulWidget {
|
||||
const SecurityPage({
|
||||
super.key,
|
||||
required this.sessionController,
|
||||
});
|
||||
|
||||
final AppSessionController sessionController;
|
||||
|
||||
@override
|
||||
State<SecurityPage> createState() => _SecurityPageState();
|
||||
}
|
||||
|
||||
class _SecurityPageState extends State<SecurityPage> {
|
||||
final TextEditingController _currentPasswordController = TextEditingController();
|
||||
final TextEditingController _newPasswordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController = TextEditingController();
|
||||
bool _submitting = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPasswordController.dispose();
|
||||
_newPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (_newPasswordController.text != _confirmPasswordController.text) {
|
||||
setState(() {
|
||||
_errorMessage = '两次输入的新密码不一致。';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.sessionController.changePassword(
|
||||
currentPassword: _currentPasswordController.text,
|
||||
newPassword: _newPasswordController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('密码修改成功。')),
|
||||
);
|
||||
_currentPasswordController.clear();
|
||||
_newPasswordController.clear();
|
||||
_confirmPasswordController.clear();
|
||||
} on ApiException catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
_errorMessage = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('账号安全')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'修改密码',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'调用现有 /api/auth/change-password 接口,不改认证逻辑。',
|
||||
style: TextStyle(color: Color(0xFF64748B), height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: _currentPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '当前密码'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _newPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '新密码'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: '确认新密码'),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
if (_errorMessage != null) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFDC2626),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('提交修改'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/media_browser_page.dart';
|
||||
|
||||
class StoragePage extends StatefulWidget {
|
||||
const StoragePage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.dependencies,
|
||||
});
|
||||
|
||||
final StorageController controller;
|
||||
final AppDependencies dependencies;
|
||||
|
||||
@override
|
||||
State<StoragePage> createState() => _StoragePageState();
|
||||
}
|
||||
|
||||
class _StoragePageState extends State<StoragePage> {
|
||||
bool _runningCleanup = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.controller.hasData) {
|
||||
widget.controller.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _runCleanup() async {
|
||||
setState(() {
|
||||
_runningCleanup = true;
|
||||
});
|
||||
try {
|
||||
final result = await widget.controller.runRetentionCleanup();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('清理任务已提交:${result.status}')),
|
||||
);
|
||||
await widget.controller.refresh(silent: true);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error.toString())),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_runningCleanup = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('存储管理')),
|
||||
body: ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (BuildContext context, _) {
|
||||
final settings = widget.controller.settings;
|
||||
final recovery = widget.controller.recoveryOverview;
|
||||
final storage = recovery?.storage;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => widget.controller.refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
if (widget.controller.isLoading && !widget.controller.hasData)
|
||||
const SkeletonCard(height: 220)
|
||||
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
|
||||
AppErrorCard(
|
||||
message: widget.controller.errorMessage!,
|
||||
onRetry: () {
|
||||
widget.controller.refresh();
|
||||
},
|
||||
)
|
||||
else ...<Widget>[
|
||||
if (storage != null)
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'存储守护',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StorageRow(label: '状态', value: storage.isEnabled ? '已启用' : '未启用'),
|
||||
_StorageRow(label: '检查结果', value: storage.hasEnoughSpace ? '空间充足' : '空间不足'),
|
||||
_StorageRow(label: '检查路径', value: storage.checkedPath.isEmpty ? '--' : storage.checkedPath),
|
||||
_StorageRow(label: '可用空间', value: formatBytes(storage.availableBytes)),
|
||||
_StorageRow(label: '最低要求', value: formatBytes(storage.requiredBytes)),
|
||||
_StorageRow(label: '后端信息', value: storage.message.isEmpty ? '--' : storage.message),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (settings != null) ...<Widget>[
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'保留清理',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StorageRow(label: '开关', value: settings.enableRetentionCleanup ? '开启' : '关闭'),
|
||||
_StorageRow(label: '保留天数', value: '${settings.retentionDays} 天'),
|
||||
_StorageRow(label: '删除文件', value: settings.retentionDeleteFiles ? '是' : '否'),
|
||||
_StorageRow(label: '文件条件', value: settings.retentionVideoFileCondition),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _runningCleanup ? null : _runCleanup,
|
||||
icon: const Icon(Icons.cleaning_services_rounded),
|
||||
label: Text(_runningCleanup ? '提交中...' : '立即执行清理'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'恢复队列',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StorageRow(label: '待恢复直播间', value: '${recovery?.liveRooms.length ?? 0}'),
|
||||
_StorageRow(label: '待补完录像', value: '${recovery?.finalizations.length ?? 0}'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => MediaBrowserPage(
|
||||
controller: MediaBrowserController(
|
||||
mediaRepository: widget.dependencies.mediaRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.folder_open_rounded),
|
||||
label: const Text('打开文件浏览器'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if ((recovery?.liveRooms.isEmpty ?? true) && (recovery?.finalizations.isEmpty ?? true))
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: AppEmptyState(
|
||||
title: '暂无恢复项',
|
||||
description: '当前没有需要人工关注的恢复队列。',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StorageRow extends StatelessWidget {
|
||||
const _StorageRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/network/api_exception.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
|
||||
|
||||
class SystemSummaryPage extends StatefulWidget {
|
||||
const SystemSummaryPage({
|
||||
super.key,
|
||||
required this.settingsRepository,
|
||||
required this.initialSettings,
|
||||
});
|
||||
|
||||
final SettingsRepository settingsRepository;
|
||||
final SystemSettings? initialSettings;
|
||||
|
||||
@override
|
||||
State<SystemSummaryPage> createState() => _SystemSummaryPageState();
|
||||
}
|
||||
|
||||
class _SystemSummaryPageState extends State<SystemSummaryPage> {
|
||||
SystemSettings? _settings;
|
||||
bool _loading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialSettings != null) {
|
||||
_settings = widget.initialSettings;
|
||||
_loading = false;
|
||||
} else {
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
try {
|
||||
_settings = await widget.settingsRepository.getSettings();
|
||||
} on ApiException catch (error) {
|
||||
_errorMessage = error.message;
|
||||
} catch (error) {
|
||||
_errorMessage = error.toString();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = _settings;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('系统设置')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
if (_loading)
|
||||
const SkeletonCard(height: 220)
|
||||
else if (_errorMessage != null && settings == null)
|
||||
AppErrorCard(
|
||||
message: _errorMessage!,
|
||||
onRetry: () {
|
||||
_load();
|
||||
},
|
||||
)
|
||||
else if (settings != null)
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'当前配置摘要',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SettingRow(label: '输出目录', value: settings.outputRoot.isEmpty ? '--' : settings.outputRoot),
|
||||
_SettingRow(label: '全局轮询间隔', value: '${settings.pollingIntervalSeconds} 秒'),
|
||||
_SettingRow(label: '自动开播录制', value: settings.autoStartRecordingOnLive ? '开启' : '关闭'),
|
||||
_SettingRow(label: '存储守护', value: settings.enableStorageGuard ? '开启' : '关闭'),
|
||||
_SettingRow(
|
||||
label: '低于阈值暂停',
|
||||
value: '${settings.pauseRecordingWhenFreeSpaceBelowMegabytes} MB',
|
||||
),
|
||||
_SettingRow(
|
||||
label: '高于阈值恢复',
|
||||
value: '${settings.resumeRecordingWhenFreeSpaceAboveMegabytes} MB',
|
||||
),
|
||||
_SettingRow(label: '保留清理', value: settings.enableRetentionCleanup ? '开启' : '关闭'),
|
||||
_SettingRow(label: '保留天数', value: '${settings.retentionDays} 天'),
|
||||
_SettingRow(label: '删除本地文件', value: settings.retentionDeleteFiles ? '是' : '否'),
|
||||
_SettingRow(label: '视频文件条件', value: settings.retentionVideoFileCondition),
|
||||
_SettingRow(
|
||||
label: '保留任务状态',
|
||||
value: settings.retentionTaskStatuses.isEmpty
|
||||
? '--'
|
||||
: settings.retentionTaskStatuses.map(taskStatusLabel).join(' / '),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingRow extends StatelessWidget {
|
||||
const _SettingRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: 112,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class BackendAddressFormCard extends StatelessWidget {
|
||||
const BackendAddressFormCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.controller,
|
||||
required this.actionLabel,
|
||||
required this.onSubmit,
|
||||
required this.isSubmitting,
|
||||
this.errorText,
|
||||
this.note,
|
||||
this.onFieldSubmitted,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final TextEditingController controller;
|
||||
final String actionLabel;
|
||||
final VoidCallback onSubmit;
|
||||
final bool isSubmitting;
|
||||
final String? errorText;
|
||||
final String? note;
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
description,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
if (note != null) ...<Widget>[
|
||||
const SizedBox(height: 18),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
note!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1D4ED8),
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onSubmitted: onFieldSubmitted,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '后端地址',
|
||||
hintText: 'https://api.example.com',
|
||||
helperText: '支持 http/https,可保留子路径,例如 https://example.com/live-recorder',
|
||||
prefixIcon: Icon(Icons.link_rounded),
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...<Widget>[
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
errorText!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFDC2626),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: isSubmitting ? null : onSubmit,
|
||||
child: isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(actionLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class ClusterStatusCard extends StatelessWidget {
|
||||
const ClusterStatusCard({
|
||||
super.key,
|
||||
required this.healthLabel,
|
||||
required this.nodeCountLabel,
|
||||
required this.concurrentRecordingLabel,
|
||||
required this.storageLabel,
|
||||
});
|
||||
|
||||
final String healthLabel;
|
||||
final String nodeCountLabel;
|
||||
final String concurrentRecordingLabel;
|
||||
final String storageLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = <({String label, String value})>[
|
||||
(label: '健康状态', value: healthLabel),
|
||||
(label: '节点数量', value: nodeCountLabel),
|
||||
(label: '并发录制', value: concurrentRecordingLabel),
|
||||
(label: '存储状态', value: storageLabel),
|
||||
];
|
||||
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'集群概览',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final isTwoColumn = constraints.maxWidth >= 320;
|
||||
final itemWidth =
|
||||
isTwoColumn ? (constraints.maxWidth - 12) / 2 : constraints.maxWidth;
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: items.map((({String label, String value}) item) {
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
child: _FactCard(
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FactCard extends StatelessWidget {
|
||||
const _FactCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class RecordingFileCard extends StatefulWidget {
|
||||
const RecordingFileCard({
|
||||
super.key,
|
||||
required this.task,
|
||||
required this.detail,
|
||||
required this.onTap,
|
||||
required this.onDownload,
|
||||
required this.onVisible,
|
||||
});
|
||||
|
||||
final RecordTask task;
|
||||
final RecordTaskDetail? detail;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback onVisible;
|
||||
|
||||
@override
|
||||
State<RecordingFileCard> createState() => _RecordingFileCardState();
|
||||
}
|
||||
|
||||
class _RecordingFileCardState extends State<RecordingFileCard> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Future<void>.microtask(widget.onVisible));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fileName = (widget.task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last;
|
||||
return AppCard(
|
||||
onTap: widget.onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
fileName.isEmpty ? '--' : fileName,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
StatusBadge(
|
||||
status: widget.task.status,
|
||||
context: 'task',
|
||||
label: taskStatusLabel(widget.task.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.task.liveRoomTitle.isEmpty ? '--' : widget.task.liveRoomTitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: <Widget>[
|
||||
_InfoPill(label: '大小', value: formatBytes(widget.detail?.result?.fileSizeBytes)),
|
||||
_InfoPill(label: '时长', value: formatDurationSeconds(widget.detail?.result?.durationSeconds ?? widget.task.durationSeconds)),
|
||||
_InfoPill(label: '创建时间', value: formatDateTime(widget.task.createdAt)),
|
||||
_InfoPill(label: '所属房间', value: widget.task.roomId.isEmpty ? '--' : widget.task.roomId),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
FilledButton.tonal(
|
||||
onPressed: widget.onTap,
|
||||
child: const Text('详情'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: widget.onDownload,
|
||||
child: const Text('下载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoPill extends StatelessWidget {
|
||||
const _InfoPill({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
'$label · $value',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF475569),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class RoomCard extends StatelessWidget {
|
||||
const RoomCard({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.session,
|
||||
required this.recentEvent,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final LiveRoom room;
|
||||
final RecordSession? session;
|
||||
final String recentEvent;
|
||||
final VoidCallback onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
room.title ?? room.anchorName ?? room.roomId,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${room.platformName.isEmpty ? '--' : room.platformName} · ${room.roomId.isEmpty ? '--' : room.roomId}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
StatusBadge(
|
||||
status: room.availabilityStatus,
|
||||
context: 'availability',
|
||||
label: availabilityLabel(room.availabilityStatus),
|
||||
),
|
||||
StatusBadge(
|
||||
status: room.currentRecordingState,
|
||||
context: 'recording',
|
||||
label: recordingStateLabel(room.currentRecordingState),
|
||||
),
|
||||
if (room.isPinned) ...const <Widget>[StatusBadge(status: 'completed', label: '置顶')],
|
||||
if (room.isPriority) ...const <Widget>[StatusBadge(status: 'retrying', label: '重点')],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: <Widget>[
|
||||
_Fact(label: '在线人数', value: '--'),
|
||||
_Fact(label: '码率', value: '--'),
|
||||
_Fact(label: '录制时长', value: formatDurationSeconds(_recordingDurationSeconds)),
|
||||
_Fact(label: '采集账号', value: '--'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'最近事件 · $recentEvent',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
num? get _recordingDurationSeconds {
|
||||
final startedAt = DateTime.tryParse(session?.startedAt ?? '');
|
||||
if (startedAt == null) {
|
||||
return session?.tasks.isNotEmpty == true ? session?.tasks.last.durationSeconds : null;
|
||||
}
|
||||
return DateTime.now().difference(startedAt.toLocal()).inSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
class _Fact extends StatelessWidget {
|
||||
const _Fact({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
'$label · $value',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF475569),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class RoomPreviewCard extends StatelessWidget {
|
||||
const RoomPreviewCard({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.session,
|
||||
required this.recentEvent,
|
||||
required this.onTap,
|
||||
this.onWatchLive,
|
||||
});
|
||||
|
||||
final LiveRoom room;
|
||||
final RecordSession? session;
|
||||
final String recentEvent;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onWatchLive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preview = room.coverUrl;
|
||||
final canWatchLive = hasLiveRoomWatchSource(room);
|
||||
|
||||
return AppCard(
|
||||
onTap: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: <Widget>[
|
||||
if (preview != null && preview.isNotEmpty)
|
||||
Image.network(
|
||||
preview,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (
|
||||
BuildContext context,
|
||||
Object error,
|
||||
StackTrace? stackTrace,
|
||||
) =>
|
||||
_FallbackPreview(room: room),
|
||||
)
|
||||
else
|
||||
_FallbackPreview(room: room),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[
|
||||
Colors.black.withValues(alpha: 0.08),
|
||||
Colors.black.withValues(alpha: 0.42),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 12,
|
||||
top: 12,
|
||||
child: StatusBadge(
|
||||
status: room.availabilityStatus,
|
||||
context: 'availability',
|
||||
label: availabilityLabel(room.availabilityStatus),
|
||||
),
|
||||
),
|
||||
if (canWatchLive && onWatchLive != null)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
child: IconButton.filledTonal(
|
||||
onPressed: onWatchLive,
|
||||
tooltip: '观看直播',
|
||||
icon: const Icon(Icons.play_circle_fill_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
room.title ?? room.anchorName ?? room.roomId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${room.platformName.isEmpty ? '--' : room.platformName} · Room ${room.roomId.isEmpty ? '--' : room.roomId}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
StatusBadge(
|
||||
status: room.currentRecordingState,
|
||||
context: 'recording',
|
||||
label: recordingStateLabel(room.currentRecordingState),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _PreviewFact(
|
||||
label: '在线人数',
|
||||
value: '--',
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _PreviewFact(
|
||||
label: '码率',
|
||||
value: '--',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _PreviewFact(
|
||||
label: '录制时长',
|
||||
value: formatDurationSeconds(_duration),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _PreviewFact(
|
||||
label: '最近事件',
|
||||
value: recentEvent,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
num? get _duration {
|
||||
final startedAt = DateTime.tryParse(session?.startedAt ?? '');
|
||||
if (startedAt == null) {
|
||||
return session?.tasks.isNotEmpty == true
|
||||
? session?.tasks.last.durationSeconds
|
||||
: null;
|
||||
}
|
||||
return DateTime.now().difference(startedAt.toLocal()).inSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewFact extends StatelessWidget {
|
||||
const _PreviewFact({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.maxLines = 1,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final int maxLines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value.isEmpty ? '--' : value,
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackPreview extends StatelessWidget {
|
||||
const _FallbackPreview({
|
||||
this.room,
|
||||
});
|
||||
|
||||
final LiveRoom? room;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[Color(0xFF0F172A), Color(0xFF1E293B)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
room?.platformName ?? 'LiveRecorder',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:live_recorder_mobile/app/app.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(
|
||||
LiveRecorderBootstrap(
|
||||
config: ApiConfig.fromEnvironment(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.8"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
@@ -0,0 +1,24 @@
|
||||
name: live_recorder_mobile
|
||||
description: "LiveRecorder Android console built with Flutter."
|
||||
publish_to: "none"
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.6.0
|
||||
intl: ^0.20.2
|
||||
path_provider: ^2.1.5
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/core/network/api_exception.dart';
|
||||
|
||||
void main() {
|
||||
group('ApiClient', () {
|
||||
test('injects bearer token into request headers', () async {
|
||||
final client = ApiClient(
|
||||
baseUrl: 'https://example.com',
|
||||
tokenProvider: () => 'token-123',
|
||||
onUnauthorized: () async {},
|
||||
client: _FakeHttpClient((http.BaseRequest request) async {
|
||||
expect(request.headers['Authorization'], 'Bearer token-123');
|
||||
return _jsonResponse(<String, dynamic>{'ok': true});
|
||||
}),
|
||||
);
|
||||
|
||||
final response = await client.getJson('/api/test') as Map<String, dynamic>;
|
||||
|
||||
expect(response['ok'], isTrue);
|
||||
});
|
||||
|
||||
test('preserves backend subpath when building request URLs', () async {
|
||||
final client = ApiClient(
|
||||
baseUrl: 'https://example.com/live-recorder',
|
||||
tokenProvider: () => null,
|
||||
onUnauthorized: () async {},
|
||||
client: _FakeHttpClient((http.BaseRequest request) async {
|
||||
expect(
|
||||
request.url.toString(),
|
||||
'https://example.com/live-recorder/api/test',
|
||||
);
|
||||
return _jsonResponse(<String, dynamic>{'ok': true});
|
||||
}),
|
||||
);
|
||||
|
||||
await client.getJson('/api/test');
|
||||
});
|
||||
|
||||
test('triggers unauthorized callback on 401 response', () async {
|
||||
var unauthorizedCalled = false;
|
||||
|
||||
final client = ApiClient(
|
||||
baseUrl: 'https://example.com',
|
||||
tokenProvider: () => null,
|
||||
onUnauthorized: () async {
|
||||
unauthorizedCalled = true;
|
||||
},
|
||||
client: _FakeHttpClient((http.BaseRequest request) async {
|
||||
return _jsonResponse(
|
||||
<String, dynamic>{'message': 'unauthorized'},
|
||||
statusCode: 401,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.getJson('/api/test'),
|
||||
throwsA(
|
||||
isA<ApiException>().having(
|
||||
(ApiException error) => error.message,
|
||||
'message',
|
||||
'unauthorized',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(unauthorizedCalled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeHttpClient extends http.BaseClient {
|
||||
_FakeHttpClient(this._handler);
|
||||
|
||||
final Future<http.StreamedResponse> Function(http.BaseRequest request) _handler;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
return _handler(request);
|
||||
}
|
||||
}
|
||||
|
||||
http.StreamedResponse _jsonResponse(
|
||||
Map<String, dynamic> body, {
|
||||
int statusCode = 200,
|
||||
}) {
|
||||
final bytes = utf8.encode(jsonEncode(body));
|
||||
return http.StreamedResponse(
|
||||
Stream<List<int>>.value(bytes),
|
||||
statusCode,
|
||||
headers: const <String, String>{
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
|
||||
|
||||
void main() {
|
||||
group('AppBootstrapController', () {
|
||||
test('stays unconfigured when no backend address is stored', () async {
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(seedBaseUrl: 'https://seed.example.com'),
|
||||
configStorage: _FakeBackendConfigStore(),
|
||||
dependenciesFactory: _FakeDependencyBundle.new,
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
|
||||
expect(controller.hasConfiguredBackend, isFalse);
|
||||
expect(controller.backendBaseUrl, isNull);
|
||||
expect(controller.dependencies, isNull);
|
||||
});
|
||||
|
||||
test('restores dependencies from stored backend address', () async {
|
||||
final store = _FakeBackendConfigStore(
|
||||
storedBaseUrl: 'https://example.com/live-recorder/',
|
||||
);
|
||||
final createdBundles = <_FakeDependencyBundle>[];
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(seedBaseUrl: 'https://seed.example.com'),
|
||||
configStorage: store,
|
||||
dependenciesFactory: (String baseUrl) {
|
||||
final bundle = _FakeDependencyBundle(baseUrl);
|
||||
createdBundles.add(bundle);
|
||||
return bundle;
|
||||
},
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
|
||||
expect(controller.backendBaseUrl, 'https://example.com/live-recorder');
|
||||
expect(createdBundles, hasLength(1));
|
||||
expect(createdBundles.single.baseUrl, 'https://example.com/live-recorder');
|
||||
expect(createdBundles.single.session.restoreCount, 1);
|
||||
});
|
||||
|
||||
test('does not rebuild dependencies when backend address is unchanged', () async {
|
||||
final store = _FakeBackendConfigStore(
|
||||
storedBaseUrl: 'https://example.com/api',
|
||||
);
|
||||
final createdBundles = <_FakeDependencyBundle>[];
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(),
|
||||
configStorage: store,
|
||||
dependenciesFactory: (String baseUrl) {
|
||||
final bundle = _FakeDependencyBundle(baseUrl);
|
||||
createdBundles.add(bundle);
|
||||
return bundle;
|
||||
},
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
final changed = await controller.updateBackendBaseUrl('https://example.com/api/');
|
||||
|
||||
expect(changed, isFalse);
|
||||
expect(createdBundles, hasLength(1));
|
||||
expect(createdBundles.single.session.clearLocalSessionCount, 0);
|
||||
});
|
||||
|
||||
test('rebuilds dependencies and clears session when backend changes', () async {
|
||||
final store = _FakeBackendConfigStore(
|
||||
storedBaseUrl: 'https://example.com/api',
|
||||
);
|
||||
final createdBundles = <_FakeDependencyBundle>[];
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(),
|
||||
configStorage: store,
|
||||
dependenciesFactory: (String baseUrl) {
|
||||
final bundle = _FakeDependencyBundle(baseUrl);
|
||||
createdBundles.add(bundle);
|
||||
return bundle;
|
||||
},
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
final firstBundle = createdBundles.single;
|
||||
|
||||
final changed = await controller.updateBackendBaseUrl('https://new.example.com/root/');
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(store.storedBaseUrl, 'https://new.example.com/root');
|
||||
expect(firstBundle.session.clearLocalSessionCount, 1);
|
||||
expect(firstBundle.isDisposed, isTrue);
|
||||
expect(createdBundles, hasLength(2));
|
||||
expect(createdBundles.last.baseUrl, 'https://new.example.com/root');
|
||||
expect(createdBundles.last.session.restoreCount, 1);
|
||||
expect(controller.backendBaseUrl, 'https://new.example.com/root');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeBackendConfigStore implements BackendConfigStore {
|
||||
_FakeBackendConfigStore({
|
||||
this.storedBaseUrl,
|
||||
});
|
||||
|
||||
String? storedBaseUrl;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
storedBaseUrl = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readBackendBaseUrl() async => storedBaseUrl;
|
||||
|
||||
@override
|
||||
Future<void> writeBackendBaseUrl(String baseUrl) async {
|
||||
storedBaseUrl = baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeDependencyBundle implements AppDependencyBundle {
|
||||
_FakeDependencyBundle(this.baseUrl);
|
||||
|
||||
final String baseUrl;
|
||||
final _FakeSessionController session = _FakeSessionController();
|
||||
bool isDisposed = false;
|
||||
|
||||
@override
|
||||
SessionControllerHandle get sessionController => session;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
isDisposed = true;
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeSessionController extends ChangeNotifier implements SessionControllerHandle {
|
||||
int restoreCount = 0;
|
||||
int clearLocalSessionCount = 0;
|
||||
|
||||
@override
|
||||
bool get isLoggedIn => false;
|
||||
|
||||
@override
|
||||
bool get isRestoring => false;
|
||||
|
||||
@override
|
||||
Future<void> clearLocalSession() async {
|
||||
clearLocalSessionCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restore() async {
|
||||
restoreCount += 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
|
||||
|
||||
void main() {
|
||||
group('normalizeBackendBaseUrl', () {
|
||||
test('trims and removes trailing slash while preserving subpath', () {
|
||||
final value = normalizeBackendBaseUrl(' https://example.com/live-recorder/ ');
|
||||
|
||||
expect(value, 'https://example.com/live-recorder');
|
||||
});
|
||||
|
||||
test('rejects non-http schemes', () {
|
||||
expect(
|
||||
() => normalizeBackendBaseUrl('ftp://example.com'),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(FormatException error) => error.message,
|
||||
'message',
|
||||
'请输入以 http:// 或 https:// 开头的完整地址',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('prefills setup page with seed backend address', (WidgetTester tester) async {
|
||||
final handle = _FakeBackendConfigHandle(
|
||||
seedBaseUrl: 'https://seed.example.com/live-recorder',
|
||||
currentBackendBaseUrl: '',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: BackendSetupPage(
|
||||
bootstrapController: handle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(textField.controller?.text, 'https://seed.example.com/live-recorder');
|
||||
});
|
||||
|
||||
testWidgets('shows validation error for invalid backend address', (WidgetTester tester) async {
|
||||
final handle = _FakeBackendConfigHandle();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: BackendSetupPage(
|
||||
bootstrapController: handle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ftp://example.com');
|
||||
await tester.tap(find.text('保存并继续'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('请输入以 http:// 或 https:// 开头的完整地址'), findsOneWidget);
|
||||
expect(handle.savedValues, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeBackendConfigHandle extends ChangeNotifier implements BackendConfigHandle {
|
||||
_FakeBackendConfigHandle({
|
||||
this.seedBaseUrl = '',
|
||||
this.currentBackendBaseUrl = '',
|
||||
});
|
||||
|
||||
final String currentBackendBaseUrl;
|
||||
final List<String> savedValues = <String>[];
|
||||
|
||||
@override
|
||||
final String seedBaseUrl;
|
||||
|
||||
@override
|
||||
String? get backendBaseUrl => currentBackendBaseUrl.isEmpty ? null : currentBackendBaseUrl;
|
||||
|
||||
@override
|
||||
bool get hasConfiguredBackend => backendBaseUrl != null;
|
||||
|
||||
@override
|
||||
String? get initializationErrorMessage => null;
|
||||
|
||||
@override
|
||||
bool get isInitializing => false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
|
||||
savedValues.add(rawValue);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updateBackendBaseUrl(String rawValue) async => false;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/recovery_formatters.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
void main() {
|
||||
group('deriveRelativeMediaPath', () {
|
||||
test('returns relative path when file is under output root', () {
|
||||
final value = deriveRelativeMediaPath(
|
||||
outputRoot: r'C:\records',
|
||||
outputFilePath: r'C:\records\2026\demo.mp4',
|
||||
);
|
||||
|
||||
expect(value, '2026/demo.mp4');
|
||||
});
|
||||
|
||||
test('blocks unsafe traversal paths', () {
|
||||
final value = deriveRelativeMediaPath(
|
||||
outputRoot: '/records',
|
||||
outputFilePath: '../secret.mp4',
|
||||
);
|
||||
|
||||
expect(value, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('SystemSettings', () {
|
||||
test('parses retentionTaskStatuses from int list', () {
|
||||
final settings = SystemSettings.fromJson(<String, dynamic>{
|
||||
'retentionTaskStatuses': <int>[4, 5, 6],
|
||||
});
|
||||
|
||||
expect(settings.retentionTaskStatuses, <int>[4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
group('storage labels', () {
|
||||
test('maps raw english storage health to friendly chinese label', () {
|
||||
const storage = StorageGuardStatus(
|
||||
isEnabled: true,
|
||||
hasEnoughSpace: true,
|
||||
checkedPath: '/records',
|
||||
availableBytes: 0,
|
||||
requiredBytes: 0,
|
||||
message: 'storage is available',
|
||||
);
|
||||
|
||||
expect(formatStorageHealthLabel(storage), '空间充足');
|
||||
expect(formatStorageUsageLabel(storage), '空间充足');
|
||||
});
|
||||
|
||||
test('formats available and required capacity when bytes exist', () {
|
||||
const storage = StorageGuardStatus(
|
||||
isEnabled: true,
|
||||
hasEnoughSpace: false,
|
||||
checkedPath: '/records',
|
||||
availableBytes: 1024 * 1024 * 1024,
|
||||
requiredBytes: 512 * 1024 * 1024,
|
||||
message: '',
|
||||
);
|
||||
|
||||
expect(formatStorageHealthLabel(storage), '空间不足');
|
||||
expect(
|
||||
formatStorageUsageLabel(storage),
|
||||
'可用 1.00 GB / 需保留 512.00 MB',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
void main() {
|
||||
group('resolveLiveRoomWatchUri', () {
|
||||
test('uses normalized url first', () {
|
||||
final room = _buildRoom(
|
||||
normalizedUrl: 'https://www.douyin.com/live/normalized',
|
||||
sourceUrl: 'https://www.douyin.com/live/source',
|
||||
originalLiveRoomUrl: 'https://www.douyin.com/live/original',
|
||||
);
|
||||
|
||||
final uri = resolveLiveRoomWatchUri(room);
|
||||
|
||||
expect(uri?.toString(), 'https://www.douyin.com/live/normalized');
|
||||
});
|
||||
|
||||
test('falls back to source and original room url', () {
|
||||
final room = _buildRoom(
|
||||
normalizedUrl: 'javascript:void(0)',
|
||||
sourceUrl: 'https://www.douyin.com/live/source',
|
||||
originalLiveRoomUrl: 'https://www.douyin.com/live/original',
|
||||
);
|
||||
|
||||
final uri = resolveLiveRoomWatchUri(room);
|
||||
|
||||
expect(uri?.toString(), 'https://www.douyin.com/live/source');
|
||||
});
|
||||
|
||||
test('returns null when no valid http url exists', () {
|
||||
final room = _buildRoom(
|
||||
normalizedUrl: 'file:///tmp/demo',
|
||||
sourceUrl: '',
|
||||
originalLiveRoomUrl: 'javascript:void(0)',
|
||||
);
|
||||
|
||||
expect(resolveLiveRoomWatchUri(room), isNull);
|
||||
expect(hasLiveRoomWatchSource(room), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('compareMonitorRooms', () {
|
||||
test('sorts live rooms before offline rooms', () {
|
||||
final liveRoom = _buildRoom(
|
||||
id: 'live',
|
||||
availabilityStatus: 2,
|
||||
currentRecordingState: 0,
|
||||
);
|
||||
final offlineRoom = _buildRoom(
|
||||
id: 'offline',
|
||||
availabilityStatus: 1,
|
||||
currentRecordingState: 2,
|
||||
);
|
||||
|
||||
final rooms = <LiveRoom>[offlineRoom, liveRoom]..sort(compareMonitorRooms);
|
||||
|
||||
expect(rooms.first.id, 'live');
|
||||
});
|
||||
|
||||
test('sorts recording rooms before non-recording rooms inside same live group', () {
|
||||
final recordingRoom = _buildRoom(
|
||||
id: 'recording',
|
||||
availabilityStatus: 2,
|
||||
currentRecordingState: 2,
|
||||
);
|
||||
final idleRoom = _buildRoom(
|
||||
id: 'idle',
|
||||
availabilityStatus: 2,
|
||||
currentRecordingState: 1,
|
||||
);
|
||||
|
||||
final rooms = <LiveRoom>[idleRoom, recordingRoom]..sort(compareMonitorRooms);
|
||||
|
||||
expect(rooms.first.id, 'recording');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LiveRoom _buildRoom({
|
||||
String id = 'room-1',
|
||||
int availabilityStatus = 1,
|
||||
int currentRecordingState = 0,
|
||||
bool isPinned = false,
|
||||
bool isPriority = false,
|
||||
String normalizedUrl = '',
|
||||
String sourceUrl = '',
|
||||
String originalLiveRoomUrl = '',
|
||||
String updatedAt = '2026-05-14T10:00:00Z',
|
||||
}) {
|
||||
return LiveRoom(
|
||||
id: id,
|
||||
platform: 1,
|
||||
platformName: 'Douyin',
|
||||
sourceUrl: sourceUrl,
|
||||
roomId: '123456',
|
||||
normalizedUrl: normalizedUrl,
|
||||
title: 'Demo room',
|
||||
anchorName: 'Anchor',
|
||||
anchorId: 'anchor-1',
|
||||
avatarUrl: null,
|
||||
coverUrl: null,
|
||||
remark: null,
|
||||
isPinned: isPinned,
|
||||
alias: null,
|
||||
isPriority: isPriority,
|
||||
pollingIntervalSecondsOverride: null,
|
||||
originalLiveRoomUrl: originalLiveRoomUrl,
|
||||
overrides: LiveRoomSettingsOverrides.fromJson(const <String, dynamic>{}),
|
||||
effectiveSettings: LiveRoomEffectiveSettings.fromJson(const <String, dynamic>{}),
|
||||
isEnabled: true,
|
||||
availabilityStatus: availabilityStatus,
|
||||
currentRecordingState: currentRecordingState,
|
||||
lastAutoStartDecisionCode: null,
|
||||
lastAutoStartDecisionSummary: null,
|
||||
lastAutoStartDecisionDetail: null,
|
||||
lastAutoStartDecisionAt: null,
|
||||
lastCheckedAt: null,
|
||||
createdAt: '2026-05-14T08:00:00Z',
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('uses green palette for live status', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatusBadge(
|
||||
status: 'live',
|
||||
label: '直播中',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(
|
||||
of: find.byType(StatusBadge),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
|
||||
expect(decoration.color, const Color(0xFFECFDF5));
|
||||
expect(find.text('直播中'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('falls back to gray palette for unknown status', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatusBadge(
|
||||
status: 'mystery',
|
||||
label: '未知',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(
|
||||
of: find.byType(StatusBadge),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
|
||||
expect(decoration.color, const Color(0xFFF1F5F9));
|
||||
expect(find.text('未知'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user