feat: add flutter mobile console and refine login ui

This commit is contained in:
2026-05-15 00:24:58 +08:00
parent d69c7d015e
commit 50b207415a
87 changed files with 9822 additions and 157 deletions
+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(),
),
);
}