feat: add flutter mobile console and refine login ui
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/app/app_theme.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/login_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/mobile_shell_page.dart';
|
||||
|
||||
class LiveRecorderBootstrap extends StatefulWidget {
|
||||
const LiveRecorderBootstrap({
|
||||
super.key,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
final ApiConfig config;
|
||||
|
||||
@override
|
||||
State<LiveRecorderBootstrap> createState() => _LiveRecorderBootstrapState();
|
||||
}
|
||||
|
||||
class _LiveRecorderBootstrapState extends State<LiveRecorderBootstrap> {
|
||||
late final AppBootstrapController<AppDependencies> _bootstrapController =
|
||||
AppBootstrapController<AppDependencies>(
|
||||
config: widget.config,
|
||||
configStorage: AppConfigStorage(),
|
||||
dependenciesFactory: (String baseUrl) => AppDependencies.create(baseUrl: baseUrl),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapController.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bootstrapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _bootstrapController,
|
||||
builder: (BuildContext context, _) {
|
||||
return AppScope(
|
||||
backendConfig: _bootstrapController,
|
||||
dependencies: _bootstrapController.dependencies,
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'LiveRecorder',
|
||||
theme: buildLiveRecorderTheme(),
|
||||
home: _BootstrapHome(
|
||||
bootstrapController: _bootstrapController,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BootstrapHome extends StatelessWidget {
|
||||
const _BootstrapHome({
|
||||
required this.bootstrapController,
|
||||
});
|
||||
|
||||
final AppBootstrapController<AppDependencies> bootstrapController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bootstrapController.isInitializing) {
|
||||
return const _LoadingSplashPage();
|
||||
}
|
||||
|
||||
if (bootstrapController.initializationErrorMessage != null) {
|
||||
return _BootstrapErrorPage(
|
||||
message: bootstrapController.initializationErrorMessage!,
|
||||
onRetry: bootstrapController.initialize,
|
||||
);
|
||||
}
|
||||
|
||||
if (!bootstrapController.hasConfiguredBackend) {
|
||||
return BackendSetupPage(
|
||||
bootstrapController: bootstrapController,
|
||||
);
|
||||
}
|
||||
|
||||
final dependencies = bootstrapController.dependencies;
|
||||
if (dependencies == null) {
|
||||
return _BootstrapErrorPage(
|
||||
message: '后端配置未能正确加载,请重试',
|
||||
onRetry: bootstrapController.initialize,
|
||||
);
|
||||
}
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: dependencies.sessionController,
|
||||
builder: (BuildContext context, _) {
|
||||
if (dependencies.sessionController.isRestoring) {
|
||||
return const _LoadingSplashPage();
|
||||
}
|
||||
|
||||
if (dependencies.sessionController.isLoggedIn) {
|
||||
return MobileShellPage(
|
||||
dependencies: dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
return LoginPage(
|
||||
sessionController: dependencies.sessionController,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingSplashPage extends StatelessWidget {
|
||||
const _LoadingSplashPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BootstrapErrorPage extends StatelessWidget {
|
||||
const _BootstrapErrorPage({
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: AppErrorCard(
|
||||
message: message,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
|
||||
|
||||
typedef AppDependenciesFactory<T extends AppDependencyBundle> = T Function(String baseUrl);
|
||||
|
||||
abstract interface class BackendConfigHandle extends Listenable {
|
||||
String get seedBaseUrl;
|
||||
String? get backendBaseUrl;
|
||||
bool get hasConfiguredBackend;
|
||||
bool get isInitializing;
|
||||
String? get initializationErrorMessage;
|
||||
|
||||
Future<void> initialize();
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue);
|
||||
Future<bool> updateBackendBaseUrl(String rawValue);
|
||||
}
|
||||
|
||||
class AppBootstrapController<T extends AppDependencyBundle> extends ChangeNotifier
|
||||
implements BackendConfigHandle {
|
||||
AppBootstrapController({
|
||||
required ApiConfig config,
|
||||
required BackendConfigStore configStorage,
|
||||
required AppDependenciesFactory<T> dependenciesFactory,
|
||||
}) : _config = config,
|
||||
_configStorage = configStorage,
|
||||
_dependenciesFactory = dependenciesFactory;
|
||||
|
||||
final ApiConfig _config;
|
||||
final BackendConfigStore _configStorage;
|
||||
final AppDependenciesFactory<T> _dependenciesFactory;
|
||||
|
||||
T? _dependencies;
|
||||
String? _backendBaseUrl;
|
||||
bool _isInitializing = true;
|
||||
String? _initializationErrorMessage;
|
||||
|
||||
T? get dependencies => _dependencies;
|
||||
|
||||
@override
|
||||
String get seedBaseUrl => _config.seedBaseUrl;
|
||||
|
||||
@override
|
||||
String? get backendBaseUrl => _backendBaseUrl;
|
||||
|
||||
@override
|
||||
bool get hasConfiguredBackend => _backendBaseUrl != null && _backendBaseUrl!.isNotEmpty;
|
||||
|
||||
@override
|
||||
bool get isInitializing => _isInitializing;
|
||||
|
||||
@override
|
||||
String? get initializationErrorMessage => _initializationErrorMessage;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
_setInitializing(true);
|
||||
try {
|
||||
final storedBaseUrl = await _configStorage.readBackendBaseUrl();
|
||||
if (storedBaseUrl == null || storedBaseUrl.trim().isEmpty) {
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(storedBaseUrl);
|
||||
_backendBaseUrl = normalizedBaseUrl;
|
||||
await _rebuildDependencies(normalizedBaseUrl);
|
||||
} on FormatException {
|
||||
await _configStorage.clear();
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
} catch (_) {
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
_initializationErrorMessage = '读取后端地址失败,请重试';
|
||||
} finally {
|
||||
_setInitializing(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
|
||||
await _persistAndApplyBackendBaseUrl(
|
||||
normalizedBaseUrl,
|
||||
clearExistingSession: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updateBackendBaseUrl(String rawValue) async {
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
|
||||
if (normalizedBaseUrl == _backendBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _persistAndApplyBackendBaseUrl(
|
||||
normalizedBaseUrl,
|
||||
clearExistingSession: true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _persistAndApplyBackendBaseUrl(
|
||||
String normalizedBaseUrl, {
|
||||
required bool clearExistingSession,
|
||||
}) async {
|
||||
final previousDependencies = _dependencies;
|
||||
_setInitializing(true);
|
||||
|
||||
try {
|
||||
await _configStorage.writeBackendBaseUrl(normalizedBaseUrl);
|
||||
if (clearExistingSession) {
|
||||
await previousDependencies?.sessionController.clearLocalSession();
|
||||
}
|
||||
|
||||
_backendBaseUrl = normalizedBaseUrl;
|
||||
await _rebuildDependencies(normalizedBaseUrl);
|
||||
} catch (error) {
|
||||
if (!identical(previousDependencies, _dependencies)) {
|
||||
_dependencies?.dispose();
|
||||
_dependencies = previousDependencies;
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
_setInitializing(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rebuildDependencies(String baseUrl) async {
|
||||
final nextDependencies = _dependenciesFactory(baseUrl);
|
||||
final previousDependencies = _dependencies;
|
||||
_dependencies = nextDependencies;
|
||||
|
||||
try {
|
||||
await nextDependencies.sessionController.restore();
|
||||
previousDependencies?.dispose();
|
||||
} catch (_) {
|
||||
nextDependencies.dispose();
|
||||
_dependencies = previousDependencies;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _setInitializing(bool value) {
|
||||
_isInitializing = value;
|
||||
if (value) {
|
||||
_initializationErrorMessage = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _disposeDependencies() {
|
||||
final dependencies = _dependencies;
|
||||
_dependencies = null;
|
||||
dependencies?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeDependencies();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/logs_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
|
||||
|
||||
abstract interface class AppDependencyBundle {
|
||||
SessionControllerHandle get sessionController;
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class AppDependencies implements AppDependencyBundle {
|
||||
AppDependencies._({
|
||||
required this.backendBaseUrl,
|
||||
required this.apiClient,
|
||||
required this.sessionStorage,
|
||||
required this.authRepository,
|
||||
required this.liveRoomsRepository,
|
||||
required this.recordingsRepository,
|
||||
required this.recoveryRepository,
|
||||
required this.settingsRepository,
|
||||
required this.logsRepository,
|
||||
required this.mediaRepository,
|
||||
required this.sessionController,
|
||||
});
|
||||
|
||||
factory AppDependencies.create({
|
||||
required String baseUrl,
|
||||
}) {
|
||||
late AppSessionController sessionController;
|
||||
final sessionStorage = SessionStorage();
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: baseUrl,
|
||||
tokenProvider: () => sessionController.token,
|
||||
onUnauthorized: () async => sessionController.handleUnauthorized(),
|
||||
);
|
||||
final authRepository = AuthRepository(apiClient);
|
||||
sessionController = AppSessionController(
|
||||
authRepository: authRepository,
|
||||
sessionStorage: sessionStorage,
|
||||
);
|
||||
|
||||
return AppDependencies._(
|
||||
backendBaseUrl: baseUrl,
|
||||
apiClient: apiClient,
|
||||
sessionStorage: sessionStorage,
|
||||
authRepository: authRepository,
|
||||
liveRoomsRepository: LiveRoomsRepository(apiClient),
|
||||
recordingsRepository: RecordingsRepository(apiClient),
|
||||
recoveryRepository: RecoveryRepository(apiClient),
|
||||
settingsRepository: SettingsRepository(apiClient),
|
||||
logsRepository: LogsRepository(apiClient),
|
||||
mediaRepository: MediaRepository(apiClient),
|
||||
sessionController: sessionController,
|
||||
);
|
||||
}
|
||||
|
||||
final String backendBaseUrl;
|
||||
final ApiClient apiClient;
|
||||
final SessionStorage sessionStorage;
|
||||
final AuthRepository authRepository;
|
||||
final LiveRoomsRepository liveRoomsRepository;
|
||||
final RecordingsRepository recordingsRepository;
|
||||
final RecoveryRepository recoveryRepository;
|
||||
final SettingsRepository settingsRepository;
|
||||
final LogsRepository logsRepository;
|
||||
final MediaRepository mediaRepository;
|
||||
@override
|
||||
final AppSessionController sessionController;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
apiClient.dispose();
|
||||
sessionController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app_bootstrap_controller.dart';
|
||||
import 'app_dependencies.dart';
|
||||
|
||||
class AppScope extends InheritedWidget {
|
||||
const AppScope({
|
||||
super.key,
|
||||
required this.backendConfig,
|
||||
required this.dependencies,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final BackendConfigHandle backendConfig;
|
||||
final AppDependencies? dependencies;
|
||||
|
||||
static AppDependencies of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope is not available in this context.');
|
||||
final dependencies = scope!.dependencies;
|
||||
assert(dependencies != null, 'AppDependencies are not available in this context.');
|
||||
return dependencies!;
|
||||
}
|
||||
|
||||
static BackendConfigHandle backendConfigOf(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope is not available in this context.');
|
||||
return scope!.backendConfig;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(AppScope oldWidget) {
|
||||
return dependencies != oldWidget.dependencies || backendConfig != oldWidget.backendConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ThemeData buildLiveRecorderTheme() {
|
||||
const seed = Color(0xFF2563EB);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
primary: seed,
|
||||
surface: Colors.white,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF6F8FB),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
side: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFF0F172A),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 72,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle?>(
|
||||
(Set<WidgetState> states) {
|
||||
final color = states.contains(WidgetState.selected)
|
||||
? const Color(0xFF2563EB)
|
||||
: const Color(0xFF64748B);
|
||||
return TextStyle(
|
||||
color: color,
|
||||
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500,
|
||||
);
|
||||
},
|
||||
),
|
||||
indicatorColor: const Color(0xFFE0ECFF),
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
hintStyle: const TextStyle(color: Color(0xFF64748B)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFF2563EB), width: 1.4),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
selectedColor: const Color(0xFFE0ECFF),
|
||||
side: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
labelStyle: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Color(0xFFE2E8F0),
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user