feat: add flutter mobile console and refine login ui #2

Merged
nanxun merged 1 commits from codex/mobile-console-pr-clean into main 2026-05-15 00:31:43 +08:00
87 changed files with 9822 additions and 157 deletions
+1
View File
@@ -5,6 +5,7 @@
**/*.suo
frontend/node_modules/
frontend/dist/
.codex-temp/
build.log
webapi-build.log
webapi-build-no-restore.log
+16 -157
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
.dart_tool/
.tmp/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub/
build/
coverage/
android/.gradle/
android/local.properties
+24
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
avoid_print: false
+39
View File
@@ -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>
+38
View File
@@ -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)
}
+3
View File
@@ -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
+160
View File
@@ -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 "$@"
+90
View File
@@ -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
+29
View File
@@ -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")
+161
View File
@@ -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();
}
}
+81
View File
@@ -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();
}
}
+35
View File
@@ -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;
}
}
+81
View File
@@ -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,
),
);
}
+14
View File
@@ -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;
}
+210
View File
@@ -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;
}
}
+89
View File
@@ -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;
}
+43
View File
@@ -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;
}
+188
View File
@@ -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;
}
+35
View File
@@ -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),
),
),
),
);
}
}
+72
View File
@@ -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),
),
),
),
);
},
);
}
}
+102
View File
@@ -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;
}
@@ -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,
),
),
),
],
),
);
}
}
@@ -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,
),
),
),
);
}
}
+13
View File
@@ -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(),
),
);
}
+514
View File
@@ -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"
+24
View File
@@ -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
+99
View File
@@ -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;
}
}
+25
View File
@@ -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:// 开头的完整地址',
),
),
);
});
});
}
+79
View File
@@ -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;
}
+69
View File
@@ -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',
);
});
});
}
+122
View File
@@ -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,
);
}
+53
View File
@@ -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);
});
}