правки лаунчера

This commit is contained in:
2026-03-22 14:15:50 +04:00
parent 8ed2b692b6
commit 4edfb52396
14 changed files with 533 additions and 54 deletions
+36 -5
View File
@@ -29,17 +29,20 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
_preferencesRepository = preferencesRepository,
_session = session,
_manifest = manifest,
super(HomeScreenState(
model: HomeScreenModel.initial().copyWith(
isAuthenticated: true,
remoteBuildHash: manifest.buildHash,
super(
HomeScreenState(
model: HomeScreenModel.initial().copyWith(
isAuthenticated: true,
remoteBuildHash: manifest.buildHash,
),
),
)) {
) {
on<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
@@ -220,6 +223,34 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
}
}
Future<void> _onHomeScreenLogoutRequested(
HomeScreenLogoutRequested event,
Emitter<HomeScreenState> emit,
) async {
_pauseRequested = true;
await _syncSubscription?.cancel();
_syncSubscription = null;
await _preferencesRepository.clearLauncherSession();
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.idle,
isAuthenticated: false,
progress: const DownloadProgress.initial(),
statusText:
'Сессия завершена. Войдите снова.',
currentPath: null,
errorMessage: null,
localBuildHash: null,
remoteBuildHash: null,
processedFiles: 0,
totalFiles: 0,
),
),
);
}
Future<void> _onHomeScreenSyncStatusChanged(
HomeScreenSyncStatusChanged event,
Emitter<HomeScreenState> emit,
@@ -13,6 +13,8 @@ final class HomeScreenPauseRequested extends HomeScreenEvent {}
final class HomeScreenPlayRequested extends HomeScreenEvent {}
final class HomeScreenLogoutRequested extends HomeScreenEvent {}
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
final ClientSyncStatus status;
+46 -27
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart';
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
@@ -28,35 +30,50 @@ class HomeScreen extends StatelessWidget {
session: session,
manifest: manifest,
),
child: Scaffold(
body: Stack(
children: [
// Background image
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
child: BlocListener<HomeScreenBloc, HomeScreenState>(
listenWhen: (previous, current) =>
previous.model.isAuthenticated && !current.model.isAuthenticated,
listener: (context, state) async {
await Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, __, ___) => const LoginScreen(),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 250),
),
// Gradient overlay
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
MWColors.abyss.withAlpha(180),
MWColors.abyss.withAlpha(220),
MWColors.abyss.withAlpha(240),
],
stops: const [0.0, 0.5, 1.0],
);
},
child: Scaffold(
body: Stack(
children: [
// Background image
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
// Gradient overlay
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
MWColors.abyss.withAlpha(180),
MWColors.abyss.withAlpha(220),
MWColors.abyss.withAlpha(240),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
),
// Main content
const Positioned.fill(child: HomeScreenContent()),
// Custom title bar
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
],
// Main content
const Positioned.fill(child: HomeScreenContent()),
// Custom title bar
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
],
),
),
),
);
@@ -95,7 +112,9 @@ class _TitleBar extends StatelessWidget {
),
_TitleBarButton(
icon: Icons.close,
onTap: () => SystemNavigator.pop(),
onTap: () {
unawaited(windowManager.destroy());
},
color: MWColors.error,
),
],
+34 -18
View File
@@ -25,10 +25,7 @@ class HomeScreenContent extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Left panel — News
Expanded(
flex: 3,
child: _NewsPanel(cs: cs),
),
Expanded(flex: 3, child: _NewsPanel(cs: cs)),
// Right panel — Status & Controls
SizedBox(
width: 320,
@@ -72,9 +69,9 @@ class _NewsPanel extends StatelessWidget {
),
Text(
'Новости',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: cs.onSurface,
),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: cs.onSurface),
),
const SizedBox(height: 12),
// News list
@@ -83,11 +80,7 @@ class _NewsPanel extends StatelessWidget {
shaderCallback: (bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white,
Colors.white,
Colors.white.withAlpha(0),
],
colors: [Colors.white, Colors.white, Colors.white.withAlpha(0)],
stops: const [0.0, 0.85, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
@@ -183,6 +176,25 @@ class _RightPanel extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => context.read<HomeScreenBloc>().add(
HomeScreenLogoutRequested(),
),
icon: const Icon(Icons.logout_rounded, size: 16),
label: const Text('Выйти'),
style: TextButton.styleFrom(
foregroundColor: cs.onSurfaceVariant,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
const SizedBox(height: 12),
const Spacer(),
// Folder selector
GestureDetector(
@@ -211,7 +223,11 @@ class _RightPanel extends StatelessWidget {
),
),
),
Icon(Icons.edit, size: 14, color: cs.onSurfaceVariant.withAlpha(120)),
Icon(
Icons.edit,
size: 14,
color: cs.onSurfaceVariant.withAlpha(120),
),
],
),
),
@@ -230,7 +246,10 @@ class _RightPanel extends StatelessWidget {
children: [
Text(
model.statusText,
style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 13),
style: TextStyle(
color: cs.onSurface.withAlpha(180),
fontSize: 13,
),
),
if (model.currentPath != null) ...[
const SizedBox(height: 6),
@@ -302,10 +321,7 @@ class _BottomBar extends StatelessWidget {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
MWColors.abyss.withAlpha(0),
MWColors.abyss.withAlpha(230),
],
colors: [MWColors.abyss.withAlpha(0), MWColors.abyss.withAlpha(230)],
),
),
child: Column(
+8 -2
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
import 'package:window_manager/window_manager.dart';
@@ -42,8 +44,10 @@ class _LoginScreenState extends State<LoginScreen> {
try {
final api = getIt<LauncherApiClient>();
final preferencesRepository = getIt<PreferencesRepository>();
final session = await api.login(username: username, password: password);
final manifest = await api.fetchManifest(session.accessToken);
await preferencesRepository.setLauncherSession(session);
if (!mounted) return;
@@ -222,7 +226,9 @@ class _TitleBar extends StatelessWidget {
),
_TitleBarButton(
icon: Icons.close,
onTap: () => SystemNavigator.pop(),
onTap: () {
unawaited(windowManager.destroy());
},
color: MWColors.error,
),
],
+105 -1
View File
@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
class MoonWellApp extends StatelessWidget {
const MoonWellApp({super.key});
@@ -9,8 +14,107 @@ class MoonWellApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'MoonWell',
home: const LoginScreen(),
home: const _LauncherBootstrapScreen(),
theme: moonWellTheme(),
);
}
}
class _LauncherBootstrapScreen extends StatefulWidget {
const _LauncherBootstrapScreen();
@override
State<_LauncherBootstrapScreen> createState() =>
_LauncherBootstrapScreenState();
}
class _LauncherBootstrapScreenState extends State<_LauncherBootstrapScreen> {
late final Future<RestoreLauncherSessionResult> _bootstrapFuture;
@override
void initState() {
super.initState();
_bootstrapFuture = RestoreLauncherSessionUseCase(
launcherApiClient: getIt<LauncherApiClient>(),
preferencesRepository: getIt<PreferencesRepository>(),
)();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<RestoreLauncherSessionResult>(
future: _bootstrapFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const _LauncherBootstrapLoadingScreen();
}
final result =
snapshot.data ??
const RestoreLauncherSessionResult.unauthenticated();
if (result.isAuthenticated) {
return HomeScreen(
session: result.session!,
manifest: result.manifest!,
);
}
return const LoginScreen();
},
);
}
}
class _LauncherBootstrapLoadingScreen extends StatelessWidget {
const _LauncherBootstrapLoadingScreen();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: [
MWColors.abyss.withAlpha(200),
MWColors.abyss.withAlpha(240),
],
),
),
),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
height: 160,
filterQuality: FilterQuality.high,
),
const SizedBox(height: 24),
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: cs.primary,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,59 @@
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
final class RestoreLauncherSessionResult {
final LauncherSession? session;
final ClientManifest? manifest;
const RestoreLauncherSessionResult._({
required this.session,
required this.manifest,
});
const RestoreLauncherSessionResult.unauthenticated()
: this._(session: null, manifest: null);
const RestoreLauncherSessionResult.authenticated({
required LauncherSession session,
required ClientManifest manifest,
}) : this._(session: session, manifest: manifest);
bool get isAuthenticated => session != null && manifest != null;
}
class RestoreLauncherSessionUseCase {
RestoreLauncherSessionUseCase({
required LauncherApiClient launcherApiClient,
required PreferencesRepository preferencesRepository,
}) : _launcherApiClient = launcherApiClient,
_preferencesRepository = preferencesRepository;
final LauncherApiClient _launcherApiClient;
final PreferencesRepository _preferencesRepository;
Future<RestoreLauncherSessionResult> call() async {
final savedSession = await _preferencesRepository.getLauncherSession();
if (savedSession == null || !savedSession.hasAccessToken) {
return const RestoreLauncherSessionResult.unauthenticated();
}
if (savedSession.isExpired) {
await _preferencesRepository.clearLauncherSession();
return const RestoreLauncherSessionResult.unauthenticated();
}
try {
final manifest = await _launcherApiClient.fetchManifest(
savedSession.accessToken,
);
return RestoreLauncherSessionResult.authenticated(
session: savedSession,
manifest: manifest,
);
} catch (_) {
return const RestoreLauncherSessionResult.unauthenticated();
}
}
}
@@ -17,6 +17,25 @@ final class LauncherSession {
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'access_token': accessToken,
'token_type': tokenType,
'expires_at': expiresAt?.toIso8601String(),
};
}
bool get hasAccessToken => accessToken.trim().isNotEmpty;
bool get isExpired {
final expiresAt = this.expiresAt;
if (expiresAt == null) {
return false;
}
return !expiresAt.toUtc().isAfter(DateTime.now().toUtc());
}
static DateTime? _parseDateTime(String? rawValue) {
if (rawValue == null || rawValue.isEmpty) {
return null;
@@ -1,8 +1,12 @@
import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _outputDirKey = 'output_dir';
const _launcherSessionKey = 'launcher_session';
@LazySingleton(as: PreferencesRepository, env: ['flutter'])
class SharedPrefsPreferencesRepository implements PreferencesRepository {
@@ -27,4 +31,37 @@ class SharedPrefsPreferencesRepository implements PreferencesRepository {
Future<void> setOutputDir(Uri uri) {
return _preferences.setString(_outputDirKey, uri.toString());
}
@override
Future<LauncherSession?> getLauncherSession() {
final rawSession = _preferences.getString(_launcherSessionKey);
if (rawSession == null || rawSession.isEmpty) {
return Future.value(null);
}
try {
final decoded = jsonDecode(rawSession);
if (decoded is! Map) {
return Future.value(null);
}
final json = decoded.map((key, value) => MapEntry(key.toString(), value));
return Future.value(LauncherSession.fromJson(json));
} catch (_) {
return Future.value(null);
}
}
@override
Future<void> setLauncherSession(LauncherSession session) {
return _preferences.setString(
_launcherSessionKey,
jsonEncode(session.toJson()),
);
}
@override
Future<void> clearLauncherSession() {
return _preferences.remove(_launcherSessionKey);
}
}
@@ -1,5 +1,13 @@
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
abstract interface class PreferencesRepository {
Future<Uri?> getOutputDir();
Future<void> setOutputDir(Uri uri);
Future<LauncherSession?> getLauncherSession();
Future<void> setLauncherSession(LauncherSession session);
Future<void> clearLauncherSession();
}