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
+99
View File
@@ -0,0 +1,99 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
void main() {
group('ApiClient', () {
test('injects bearer token into request headers', () async {
final client = ApiClient(
baseUrl: 'https://example.com',
tokenProvider: () => 'token-123',
onUnauthorized: () async {},
client: _FakeHttpClient((http.BaseRequest request) async {
expect(request.headers['Authorization'], 'Bearer token-123');
return _jsonResponse(<String, dynamic>{'ok': true});
}),
);
final response = await client.getJson('/api/test') as Map<String, dynamic>;
expect(response['ok'], isTrue);
});
test('preserves backend subpath when building request URLs', () async {
final client = ApiClient(
baseUrl: 'https://example.com/live-recorder',
tokenProvider: () => null,
onUnauthorized: () async {},
client: _FakeHttpClient((http.BaseRequest request) async {
expect(
request.url.toString(),
'https://example.com/live-recorder/api/test',
);
return _jsonResponse(<String, dynamic>{'ok': true});
}),
);
await client.getJson('/api/test');
});
test('triggers unauthorized callback on 401 response', () async {
var unauthorizedCalled = false;
final client = ApiClient(
baseUrl: 'https://example.com',
tokenProvider: () => null,
onUnauthorized: () async {
unauthorizedCalled = true;
},
client: _FakeHttpClient((http.BaseRequest request) async {
return _jsonResponse(
<String, dynamic>{'message': 'unauthorized'},
statusCode: 401,
);
}),
);
await expectLater(
client.getJson('/api/test'),
throwsA(
isA<ApiException>().having(
(ApiException error) => error.message,
'message',
'unauthorized',
),
),
);
expect(unauthorizedCalled, isTrue);
});
});
}
class _FakeHttpClient extends http.BaseClient {
_FakeHttpClient(this._handler);
final Future<http.StreamedResponse> Function(http.BaseRequest request) _handler;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
return _handler(request);
}
}
http.StreamedResponse _jsonResponse(
Map<String, dynamic> body, {
int statusCode = 200,
}) {
final bytes = utf8.encode(jsonEncode(body));
return http.StreamedResponse(
Stream<List<int>>.value(bytes),
statusCode,
headers: const <String, String>{
'content-type': 'application/json',
},
);
}
@@ -0,0 +1,160 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/config/api_config.dart';
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
void main() {
group('AppBootstrapController', () {
test('stays unconfigured when no backend address is stored', () async {
final controller = AppBootstrapController<_FakeDependencyBundle>(
config: const ApiConfig(seedBaseUrl: 'https://seed.example.com'),
configStorage: _FakeBackendConfigStore(),
dependenciesFactory: _FakeDependencyBundle.new,
);
await controller.initialize();
expect(controller.hasConfiguredBackend, isFalse);
expect(controller.backendBaseUrl, isNull);
expect(controller.dependencies, isNull);
});
test('restores dependencies from stored backend address', () async {
final store = _FakeBackendConfigStore(
storedBaseUrl: 'https://example.com/live-recorder/',
);
final createdBundles = <_FakeDependencyBundle>[];
final controller = AppBootstrapController<_FakeDependencyBundle>(
config: const ApiConfig(seedBaseUrl: 'https://seed.example.com'),
configStorage: store,
dependenciesFactory: (String baseUrl) {
final bundle = _FakeDependencyBundle(baseUrl);
createdBundles.add(bundle);
return bundle;
},
);
await controller.initialize();
expect(controller.backendBaseUrl, 'https://example.com/live-recorder');
expect(createdBundles, hasLength(1));
expect(createdBundles.single.baseUrl, 'https://example.com/live-recorder');
expect(createdBundles.single.session.restoreCount, 1);
});
test('does not rebuild dependencies when backend address is unchanged', () async {
final store = _FakeBackendConfigStore(
storedBaseUrl: 'https://example.com/api',
);
final createdBundles = <_FakeDependencyBundle>[];
final controller = AppBootstrapController<_FakeDependencyBundle>(
config: const ApiConfig(),
configStorage: store,
dependenciesFactory: (String baseUrl) {
final bundle = _FakeDependencyBundle(baseUrl);
createdBundles.add(bundle);
return bundle;
},
);
await controller.initialize();
final changed = await controller.updateBackendBaseUrl('https://example.com/api/');
expect(changed, isFalse);
expect(createdBundles, hasLength(1));
expect(createdBundles.single.session.clearLocalSessionCount, 0);
});
test('rebuilds dependencies and clears session when backend changes', () async {
final store = _FakeBackendConfigStore(
storedBaseUrl: 'https://example.com/api',
);
final createdBundles = <_FakeDependencyBundle>[];
final controller = AppBootstrapController<_FakeDependencyBundle>(
config: const ApiConfig(),
configStorage: store,
dependenciesFactory: (String baseUrl) {
final bundle = _FakeDependencyBundle(baseUrl);
createdBundles.add(bundle);
return bundle;
},
);
await controller.initialize();
final firstBundle = createdBundles.single;
final changed = await controller.updateBackendBaseUrl('https://new.example.com/root/');
expect(changed, isTrue);
expect(store.storedBaseUrl, 'https://new.example.com/root');
expect(firstBundle.session.clearLocalSessionCount, 1);
expect(firstBundle.isDisposed, isTrue);
expect(createdBundles, hasLength(2));
expect(createdBundles.last.baseUrl, 'https://new.example.com/root');
expect(createdBundles.last.session.restoreCount, 1);
expect(controller.backendBaseUrl, 'https://new.example.com/root');
});
});
}
class _FakeBackendConfigStore implements BackendConfigStore {
_FakeBackendConfigStore({
this.storedBaseUrl,
});
String? storedBaseUrl;
@override
Future<void> clear() async {
storedBaseUrl = null;
}
@override
Future<String?> readBackendBaseUrl() async => storedBaseUrl;
@override
Future<void> writeBackendBaseUrl(String baseUrl) async {
storedBaseUrl = baseUrl;
}
}
class _FakeDependencyBundle implements AppDependencyBundle {
_FakeDependencyBundle(this.baseUrl);
final String baseUrl;
final _FakeSessionController session = _FakeSessionController();
bool isDisposed = false;
@override
SessionControllerHandle get sessionController => session;
@override
void dispose() {
isDisposed = true;
session.dispose();
}
}
class _FakeSessionController extends ChangeNotifier implements SessionControllerHandle {
int restoreCount = 0;
int clearLocalSessionCount = 0;
@override
bool get isLoggedIn => false;
@override
bool get isRestoring => false;
@override
Future<void> clearLocalSession() async {
clearLocalSessionCount += 1;
}
@override
Future<void> restore() async {
restoreCount += 1;
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
void main() {
group('normalizeBackendBaseUrl', () {
test('trims and removes trailing slash while preserving subpath', () {
final value = normalizeBackendBaseUrl(' https://example.com/live-recorder/ ');
expect(value, 'https://example.com/live-recorder');
});
test('rejects non-http schemes', () {
expect(
() => normalizeBackendBaseUrl('ftp://example.com'),
throwsA(
isA<FormatException>().having(
(FormatException error) => error.message,
'message',
'请输入以 http:// 或 https:// 开头的完整地址',
),
),
);
});
});
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
void main() {
testWidgets('prefills setup page with seed backend address', (WidgetTester tester) async {
final handle = _FakeBackendConfigHandle(
seedBaseUrl: 'https://seed.example.com/live-recorder',
currentBackendBaseUrl: '',
);
await tester.pumpWidget(
MaterialApp(
home: BackendSetupPage(
bootstrapController: handle,
),
),
);
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.controller?.text, 'https://seed.example.com/live-recorder');
});
testWidgets('shows validation error for invalid backend address', (WidgetTester tester) async {
final handle = _FakeBackendConfigHandle();
await tester.pumpWidget(
MaterialApp(
home: BackendSetupPage(
bootstrapController: handle,
),
),
);
await tester.enterText(find.byType(TextField), 'ftp://example.com');
await tester.tap(find.text('保存并继续'));
await tester.pump();
expect(find.text('请输入以 http:// 或 https:// 开头的完整地址'), findsOneWidget);
expect(handle.savedValues, isEmpty);
});
}
class _FakeBackendConfigHandle extends ChangeNotifier implements BackendConfigHandle {
_FakeBackendConfigHandle({
this.seedBaseUrl = '',
this.currentBackendBaseUrl = '',
});
final String currentBackendBaseUrl;
final List<String> savedValues = <String>[];
@override
final String seedBaseUrl;
@override
String? get backendBaseUrl => currentBackendBaseUrl.isEmpty ? null : currentBackendBaseUrl;
@override
bool get hasConfiguredBackend => backendBaseUrl != null;
@override
String? get initializationErrorMessage => null;
@override
bool get isInitializing => false;
@override
Future<void> initialize() async {}
@override
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
savedValues.add(rawValue);
}
@override
Future<bool> updateBackendBaseUrl(String rawValue) async => false;
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
import 'package:live_recorder_mobile/core/utils/recovery_formatters.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
void main() {
group('deriveRelativeMediaPath', () {
test('returns relative path when file is under output root', () {
final value = deriveRelativeMediaPath(
outputRoot: r'C:\records',
outputFilePath: r'C:\records\2026\demo.mp4',
);
expect(value, '2026/demo.mp4');
});
test('blocks unsafe traversal paths', () {
final value = deriveRelativeMediaPath(
outputRoot: '/records',
outputFilePath: '../secret.mp4',
);
expect(value, isNull);
});
});
group('SystemSettings', () {
test('parses retentionTaskStatuses from int list', () {
final settings = SystemSettings.fromJson(<String, dynamic>{
'retentionTaskStatuses': <int>[4, 5, 6],
});
expect(settings.retentionTaskStatuses, <int>[4, 5, 6]);
});
});
group('storage labels', () {
test('maps raw english storage health to friendly chinese label', () {
const storage = StorageGuardStatus(
isEnabled: true,
hasEnoughSpace: true,
checkedPath: '/records',
availableBytes: 0,
requiredBytes: 0,
message: 'storage is available',
);
expect(formatStorageHealthLabel(storage), '空间充足');
expect(formatStorageUsageLabel(storage), '空间充足');
});
test('formats available and required capacity when bytes exist', () {
const storage = StorageGuardStatus(
isEnabled: true,
hasEnoughSpace: false,
checkedPath: '/records',
availableBytes: 1024 * 1024 * 1024,
requiredBytes: 512 * 1024 * 1024,
message: '',
);
expect(formatStorageHealthLabel(storage), '空间不足');
expect(
formatStorageUsageLabel(storage),
'可用 1.00 GB / 需保留 512.00 MB',
);
});
});
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
void main() {
group('resolveLiveRoomWatchUri', () {
test('uses normalized url first', () {
final room = _buildRoom(
normalizedUrl: 'https://www.douyin.com/live/normalized',
sourceUrl: 'https://www.douyin.com/live/source',
originalLiveRoomUrl: 'https://www.douyin.com/live/original',
);
final uri = resolveLiveRoomWatchUri(room);
expect(uri?.toString(), 'https://www.douyin.com/live/normalized');
});
test('falls back to source and original room url', () {
final room = _buildRoom(
normalizedUrl: 'javascript:void(0)',
sourceUrl: 'https://www.douyin.com/live/source',
originalLiveRoomUrl: 'https://www.douyin.com/live/original',
);
final uri = resolveLiveRoomWatchUri(room);
expect(uri?.toString(), 'https://www.douyin.com/live/source');
});
test('returns null when no valid http url exists', () {
final room = _buildRoom(
normalizedUrl: 'file:///tmp/demo',
sourceUrl: '',
originalLiveRoomUrl: 'javascript:void(0)',
);
expect(resolveLiveRoomWatchUri(room), isNull);
expect(hasLiveRoomWatchSource(room), isTrue);
});
});
group('compareMonitorRooms', () {
test('sorts live rooms before offline rooms', () {
final liveRoom = _buildRoom(
id: 'live',
availabilityStatus: 2,
currentRecordingState: 0,
);
final offlineRoom = _buildRoom(
id: 'offline',
availabilityStatus: 1,
currentRecordingState: 2,
);
final rooms = <LiveRoom>[offlineRoom, liveRoom]..sort(compareMonitorRooms);
expect(rooms.first.id, 'live');
});
test('sorts recording rooms before non-recording rooms inside same live group', () {
final recordingRoom = _buildRoom(
id: 'recording',
availabilityStatus: 2,
currentRecordingState: 2,
);
final idleRoom = _buildRoom(
id: 'idle',
availabilityStatus: 2,
currentRecordingState: 1,
);
final rooms = <LiveRoom>[idleRoom, recordingRoom]..sort(compareMonitorRooms);
expect(rooms.first.id, 'recording');
});
});
}
LiveRoom _buildRoom({
String id = 'room-1',
int availabilityStatus = 1,
int currentRecordingState = 0,
bool isPinned = false,
bool isPriority = false,
String normalizedUrl = '',
String sourceUrl = '',
String originalLiveRoomUrl = '',
String updatedAt = '2026-05-14T10:00:00Z',
}) {
return LiveRoom(
id: id,
platform: 1,
platformName: 'Douyin',
sourceUrl: sourceUrl,
roomId: '123456',
normalizedUrl: normalizedUrl,
title: 'Demo room',
anchorName: 'Anchor',
anchorId: 'anchor-1',
avatarUrl: null,
coverUrl: null,
remark: null,
isPinned: isPinned,
alias: null,
isPriority: isPriority,
pollingIntervalSecondsOverride: null,
originalLiveRoomUrl: originalLiveRoomUrl,
overrides: LiveRoomSettingsOverrides.fromJson(const <String, dynamic>{}),
effectiveSettings: LiveRoomEffectiveSettings.fromJson(const <String, dynamic>{}),
isEnabled: true,
availabilityStatus: availabilityStatus,
currentRecordingState: currentRecordingState,
lastAutoStartDecisionCode: null,
lastAutoStartDecisionSummary: null,
lastAutoStartDecisionDetail: null,
lastAutoStartDecisionAt: null,
lastCheckedAt: null,
createdAt: '2026-05-14T08:00:00Z',
updatedAt: updatedAt,
);
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
void main() {
testWidgets('uses green palette for live status', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: StatusBadge(
status: 'live',
label: '直播中',
),
),
),
);
final container = tester.widget<Container>(
find.descendant(
of: find.byType(StatusBadge),
matching: find.byType(Container),
),
);
final decoration = container.decoration! as BoxDecoration;
expect(decoration.color, const Color(0xFFECFDF5));
expect(find.text('直播中'), findsOneWidget);
});
testWidgets('falls back to gray palette for unknown status', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: StatusBadge(
status: 'mystery',
label: '未知',
),
),
),
);
final container = tester.widget<Container>(
find.descendant(
of: find.byType(StatusBadge),
matching: find.byType(Container),
),
);
final decoration = container.decoration! as BoxDecoration;
expect(decoration.color, const Color(0xFFF1F5F9));
expect(find.text('未知'), findsOneWidget);
});
}