правки лаунчера
This commit is contained in:
@@ -37,7 +37,16 @@ Authentication sequence:
|
|||||||
1. User enters launcher credentials.
|
1. User enters launcher credentials.
|
||||||
2. Launcher requests a bearer token from `POST /api/launcher/login`.
|
2. Launcher requests a bearer token from `POST /api/launcher/login`.
|
||||||
3. Launcher requests the manifest from `GET /api/launcher/manifest`.
|
3. Launcher requests the manifest from `GET /api/launcher/manifest`.
|
||||||
4. `LauncherSession` and `ClientManifest` are passed into the home screen.
|
4. Successful login persists `LauncherSession` locally.
|
||||||
|
5. On next launcher start, saved session is reused to fetch manifest again.
|
||||||
|
6. `LauncherSession` and `ClientManifest` are passed into the home screen.
|
||||||
|
|
||||||
|
Logout sequence:
|
||||||
|
|
||||||
|
1. User presses `Logout`.
|
||||||
|
2. Launcher cancels any active sync.
|
||||||
|
3. Launcher clears the locally persisted `LauncherSession`.
|
||||||
|
4. Launcher returns to the login screen.
|
||||||
|
|
||||||
File download sequence:
|
File download sequence:
|
||||||
|
|
||||||
|
|||||||
@@ -29,17 +29,20 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
_preferencesRepository = preferencesRepository,
|
_preferencesRepository = preferencesRepository,
|
||||||
_session = session,
|
_session = session,
|
||||||
_manifest = manifest,
|
_manifest = manifest,
|
||||||
super(HomeScreenState(
|
super(
|
||||||
model: HomeScreenModel.initial().copyWith(
|
HomeScreenState(
|
||||||
isAuthenticated: true,
|
model: HomeScreenModel.initial().copyWith(
|
||||||
remoteBuildHash: manifest.buildHash,
|
isAuthenticated: true,
|
||||||
|
remoteBuildHash: manifest.buildHash,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)) {
|
) {
|
||||||
on<HomeScreenLoad>(_onHomeScreenLoad);
|
on<HomeScreenLoad>(_onHomeScreenLoad);
|
||||||
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
|
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
|
||||||
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
|
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
|
||||||
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
|
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
|
||||||
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
|
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
|
||||||
|
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
|
||||||
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
|
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
|
||||||
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
|
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(
|
Future<void> _onHomeScreenSyncStatusChanged(
|
||||||
HomeScreenSyncStatusChanged event,
|
HomeScreenSyncStatusChanged event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ final class HomeScreenPauseRequested extends HomeScreenEvent {}
|
|||||||
|
|
||||||
final class HomeScreenPlayRequested extends HomeScreenEvent {}
|
final class HomeScreenPlayRequested extends HomeScreenEvent {}
|
||||||
|
|
||||||
|
final class HomeScreenLogoutRequested extends HomeScreenEvent {}
|
||||||
|
|
||||||
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
|
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
|
||||||
final ClientSyncStatus status;
|
final ClientSyncStatus status;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/bloc/home_screen_bloc.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/home_screen_content.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/app/theme/mw_theme.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||||
@@ -28,35 +30,50 @@ class HomeScreen extends StatelessWidget {
|
|||||||
session: session,
|
session: session,
|
||||||
manifest: manifest,
|
manifest: manifest,
|
||||||
),
|
),
|
||||||
child: Scaffold(
|
child: BlocListener<HomeScreenBloc, HomeScreenState>(
|
||||||
body: Stack(
|
listenWhen: (previous, current) =>
|
||||||
children: [
|
previous.model.isAuthenticated && !current.model.isAuthenticated,
|
||||||
// Background image
|
listener: (context, state) async {
|
||||||
Positioned.fill(
|
await Navigator.of(context).pushReplacement(
|
||||||
child: Image.asset('assets/background.png', fit: BoxFit.cover),
|
PageRouteBuilder(
|
||||||
|
pageBuilder: (_, __, ___) => const LoginScreen(),
|
||||||
|
transitionsBuilder: (_, animation, __, child) {
|
||||||
|
return FadeTransition(opacity: animation, child: child);
|
||||||
|
},
|
||||||
|
transitionDuration: const Duration(milliseconds: 250),
|
||||||
),
|
),
|
||||||
// Gradient overlay
|
);
|
||||||
Positioned.fill(
|
},
|
||||||
child: DecoratedBox(
|
child: Scaffold(
|
||||||
decoration: BoxDecoration(
|
body: Stack(
|
||||||
gradient: LinearGradient(
|
children: [
|
||||||
begin: Alignment.topCenter,
|
// Background image
|
||||||
end: Alignment.bottomCenter,
|
Positioned.fill(
|
||||||
colors: [
|
child: Image.asset('assets/background.png', fit: BoxFit.cover),
|
||||||
MWColors.abyss.withAlpha(180),
|
),
|
||||||
MWColors.abyss.withAlpha(220),
|
// Gradient overlay
|
||||||
MWColors.abyss.withAlpha(240),
|
Positioned.fill(
|
||||||
],
|
child: DecoratedBox(
|
||||||
stops: const [0.0, 0.5, 1.0],
|
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
|
||||||
// Main content
|
const Positioned.fill(child: HomeScreenContent()),
|
||||||
const Positioned.fill(child: HomeScreenContent()),
|
// Custom title bar
|
||||||
// Custom title bar
|
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
|
||||||
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -95,7 +112,9 @@ class _TitleBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
_TitleBarButton(
|
_TitleBarButton(
|
||||||
icon: Icons.close,
|
icon: Icons.close,
|
||||||
onTap: () => SystemNavigator.pop(),
|
onTap: () {
|
||||||
|
unawaited(windowManager.destroy());
|
||||||
|
},
|
||||||
color: MWColors.error,
|
color: MWColors.error,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,10 +25,7 @@ class HomeScreenContent extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Left panel — News
|
// Left panel — News
|
||||||
Expanded(
|
Expanded(flex: 3, child: _NewsPanel(cs: cs)),
|
||||||
flex: 3,
|
|
||||||
child: _NewsPanel(cs: cs),
|
|
||||||
),
|
|
||||||
// Right panel — Status & Controls
|
// Right panel — Status & Controls
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 320,
|
width: 320,
|
||||||
@@ -72,9 +69,9 @@ class _NewsPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Новости',
|
'Новости',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(
|
||||||
color: cs.onSurface,
|
context,
|
||||||
),
|
).textTheme.titleLarge?.copyWith(color: cs.onSurface),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// News list
|
// News list
|
||||||
@@ -83,11 +80,7 @@ class _NewsPanel extends StatelessWidget {
|
|||||||
shaderCallback: (bounds) => LinearGradient(
|
shaderCallback: (bounds) => LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [Colors.white, Colors.white, Colors.white.withAlpha(0)],
|
||||||
Colors.white,
|
|
||||||
Colors.white,
|
|
||||||
Colors.white.withAlpha(0),
|
|
||||||
],
|
|
||||||
stops: const [0.0, 0.85, 1.0],
|
stops: const [0.0, 0.85, 1.0],
|
||||||
).createShader(bounds),
|
).createShader(bounds),
|
||||||
blendMode: BlendMode.dstIn,
|
blendMode: BlendMode.dstIn,
|
||||||
@@ -183,6 +176,25 @@ class _RightPanel extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(),
|
const Spacer(),
|
||||||
// Folder selector
|
// Folder selector
|
||||||
GestureDetector(
|
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: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
model.statusText,
|
model.statusText,
|
||||||
style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 13),
|
style: TextStyle(
|
||||||
|
color: cs.onSurface.withAlpha(180),
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (model.currentPath != null) ...[
|
if (model.currentPath != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -302,10 +321,7 @@ class _BottomBar extends StatelessWidget {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [MWColors.abyss.withAlpha(0), MWColors.abyss.withAlpha(230)],
|
||||||
MWColors.abyss.withAlpha(0),
|
|
||||||
MWColors.abyss.withAlpha(230),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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/home_screen/home_screen.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/mw_theme.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/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:moonwell_launcher/service_container.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
@@ -42,8 +44,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final api = getIt<LauncherApiClient>();
|
final api = getIt<LauncherApiClient>();
|
||||||
|
final preferencesRepository = getIt<PreferencesRepository>();
|
||||||
final session = await api.login(username: username, password: password);
|
final session = await api.login(username: username, password: password);
|
||||||
final manifest = await api.fetchManifest(session.accessToken);
|
final manifest = await api.fetchManifest(session.accessToken);
|
||||||
|
await preferencesRepository.setLauncherSession(session);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -222,7 +226,9 @@ class _TitleBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
_TitleBarButton(
|
_TitleBarButton(
|
||||||
icon: Icons.close,
|
icon: Icons.close,
|
||||||
onTap: () => SystemNavigator.pop(),
|
onTap: () {
|
||||||
|
unawaited(windowManager.destroy());
|
||||||
|
},
|
||||||
color: MWColors.error,
|
color: MWColors.error,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+105
-1
@@ -1,6 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/login_screen/login_screen.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/mw_theme.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 {
|
class MoonWellApp extends StatelessWidget {
|
||||||
const MoonWellApp({super.key});
|
const MoonWellApp({super.key});
|
||||||
@@ -9,8 +14,107 @@ class MoonWellApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'MoonWell',
|
title: 'MoonWell',
|
||||||
home: const LoginScreen(),
|
home: const _LauncherBootstrapScreen(),
|
||||||
theme: moonWellTheme(),
|
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) {
|
static DateTime? _parseDateTime(String? rawValue) {
|
||||||
if (rawValue == null || rawValue.isEmpty) {
|
if (rawValue == null || rawValue.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
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:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
const _outputDirKey = 'output_dir';
|
const _outputDirKey = 'output_dir';
|
||||||
|
const _launcherSessionKey = 'launcher_session';
|
||||||
|
|
||||||
@LazySingleton(as: PreferencesRepository, env: ['flutter'])
|
@LazySingleton(as: PreferencesRepository, env: ['flutter'])
|
||||||
class SharedPrefsPreferencesRepository implements PreferencesRepository {
|
class SharedPrefsPreferencesRepository implements PreferencesRepository {
|
||||||
@@ -27,4 +31,37 @@ class SharedPrefsPreferencesRepository implements PreferencesRepository {
|
|||||||
Future<void> setOutputDir(Uri uri) {
|
Future<void> setOutputDir(Uri uri) {
|
||||||
return _preferences.setString(_outputDirKey, uri.toString());
|
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 {
|
abstract interface class PreferencesRepository {
|
||||||
Future<Uri?> getOutputDir();
|
Future<Uri?> getOutputDir();
|
||||||
|
|
||||||
Future<void> setOutputDir(Uri uri);
|
Future<void> setOutputDir(Uri uri);
|
||||||
|
|
||||||
|
Future<LauncherSession?> getLauncherSession();
|
||||||
|
|
||||||
|
Future<void> setLauncherSession(LauncherSession session);
|
||||||
|
|
||||||
|
Future<void> clearLauncherSession();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.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/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';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('RestoreLauncherSessionUseCase', () {
|
||||||
|
test('returns unauthenticated when there is no saved session', () async {
|
||||||
|
final api = _FakeLauncherApiClient();
|
||||||
|
final preferences = _FakePreferencesRepository();
|
||||||
|
final useCase = RestoreLauncherSessionUseCase(
|
||||||
|
launcherApiClient: api,
|
||||||
|
preferencesRepository: preferences,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await useCase();
|
||||||
|
|
||||||
|
expect(result.isAuthenticated, isFalse);
|
||||||
|
expect(api.fetchManifestCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears expired session and returns unauthenticated', () async {
|
||||||
|
final api = _FakeLauncherApiClient();
|
||||||
|
final preferences = _FakePreferencesRepository(
|
||||||
|
session: LauncherSession(
|
||||||
|
accessToken: 'expired-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: DateTime.now().toUtc().subtract(
|
||||||
|
const Duration(minutes: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final useCase = RestoreLauncherSessionUseCase(
|
||||||
|
launcherApiClient: api,
|
||||||
|
preferencesRepository: preferences,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await useCase();
|
||||||
|
|
||||||
|
expect(result.isAuthenticated, isFalse);
|
||||||
|
expect(preferences.clearedSession, isTrue);
|
||||||
|
expect(api.fetchManifestCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores session and manifest when saved token is valid', () async {
|
||||||
|
final manifest = ClientManifest.fromJson({
|
||||||
|
'files': [
|
||||||
|
{'path': 'Wow.exe', 'size': 5, 'sha256': 'wow-hash'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
final api = _FakeLauncherApiClient(manifest: manifest);
|
||||||
|
final preferences = _FakePreferencesRepository(
|
||||||
|
session: LauncherSession(
|
||||||
|
accessToken: 'saved-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: DateTime.now().toUtc().add(const Duration(days: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final useCase = RestoreLauncherSessionUseCase(
|
||||||
|
launcherApiClient: api,
|
||||||
|
preferencesRepository: preferences,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await useCase();
|
||||||
|
|
||||||
|
expect(result.isAuthenticated, isTrue);
|
||||||
|
expect(result.session?.accessToken, 'saved-token');
|
||||||
|
expect(result.manifest?.buildHash, manifest.buildHash);
|
||||||
|
expect(api.fetchManifestCalls, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeLauncherApiClient extends LauncherApiClient {
|
||||||
|
_FakeLauncherApiClient({ClientManifest? manifest}) : _manifest = manifest;
|
||||||
|
|
||||||
|
final ClientManifest? _manifest;
|
||||||
|
int fetchManifestCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||||
|
fetchManifestCalls += 1;
|
||||||
|
return _manifest ??
|
||||||
|
ClientManifest.fromJson(<String, Object?>{
|
||||||
|
'files': <Map<String, Object?>>[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakePreferencesRepository implements PreferencesRepository {
|
||||||
|
_FakePreferencesRepository({this.session});
|
||||||
|
|
||||||
|
LauncherSession? session;
|
||||||
|
bool clearedSession = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearLauncherSession() async {
|
||||||
|
clearedSession = true;
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LauncherSession?> getLauncherSession() async => session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uri?> getOutputDir() async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setLauncherSession(LauncherSession session) async {
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setOutputDir(Uri uri) async {}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||||
|
import 'package:moonwell_launcher/features/preferences/data/repositories/shared_prefs_preferences_repository.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SharedPrefsPreferencesRepository', () {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists launcher session between reads', () async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final repository = SharedPrefsPreferencesRepository(
|
||||||
|
preferences: preferences,
|
||||||
|
);
|
||||||
|
final session = LauncherSession(
|
||||||
|
accessToken: 'token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: DateTime.parse('2030-01-01T00:00:00Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.setLauncherSession(session);
|
||||||
|
final restoredSession = await repository.getLauncherSession();
|
||||||
|
|
||||||
|
expect(restoredSession?.accessToken, 'token');
|
||||||
|
expect(restoredSession?.tokenType, 'Bearer');
|
||||||
|
expect(
|
||||||
|
restoredSession?.expiresAt?.toUtc(),
|
||||||
|
DateTime.parse('2030-01-01T00:00:00Z'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears persisted launcher session', () async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final repository = SharedPrefsPreferencesRepository(
|
||||||
|
preferences: preferences,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.setLauncherSession(
|
||||||
|
LauncherSession(
|
||||||
|
accessToken: 'token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repository.clearLauncherSession();
|
||||||
|
|
||||||
|
expect(await repository.getLauncherSession(), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 165 KiB |
Reference in New Issue
Block a user