diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index c4e9c50..3b1d7ec 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -37,7 +37,16 @@ Authentication sequence: 1. User enters launcher credentials. 2. Launcher requests a bearer token from `POST /api/launcher/login`. 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: diff --git a/lib/app/home_screen/bloc/home_screen_bloc.dart b/lib/app/home_screen/bloc/home_screen_bloc.dart index 060cc74..2f419fa 100644 --- a/lib/app/home_screen/bloc/home_screen_bloc.dart +++ b/lib/app/home_screen/bloc/home_screen_bloc.dart @@ -29,17 +29,20 @@ class HomeScreenBloc extends Bloc { _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(_onHomeScreenLoad); on(_onHomeScreenSyncRequested); on(_onHomeScreenOutputDirRequested); on(_onHomeScreenPauseRequested); on(_onHomeScreenPlayRequested); + on(_onHomeScreenLogoutRequested); on(_onHomeScreenSyncStatusChanged); on(_onHomeScreenSyncFailed); @@ -220,6 +223,34 @@ class HomeScreenBloc extends Bloc { } } + Future _onHomeScreenLogoutRequested( + HomeScreenLogoutRequested event, + Emitter 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 _onHomeScreenSyncStatusChanged( HomeScreenSyncStatusChanged event, Emitter emit, diff --git a/lib/app/home_screen/bloc/home_screen_event.dart b/lib/app/home_screen/bloc/home_screen_event.dart index 277852b..1805dd8 100644 --- a/lib/app/home_screen/bloc/home_screen_event.dart +++ b/lib/app/home_screen/bloc/home_screen_event.dart @@ -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; diff --git a/lib/app/home_screen/home_screen.dart b/lib/app/home_screen/home_screen.dart index 21829af..511cca7 100644 --- a/lib/app/home_screen/home_screen.dart +++ b/lib/app/home_screen/home_screen.dart @@ -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( + 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, ), ], diff --git a/lib/app/home_screen/home_screen_content.dart b/lib/app/home_screen/home_screen_content.dart index 1dee5b2..ab75952 100644 --- a/lib/app/home_screen/home_screen_content.dart +++ b/lib/app/home_screen/home_screen_content.dart @@ -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().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( diff --git a/lib/app/login_screen/login_screen.dart b/lib/app/login_screen/login_screen.dart index d0636e6..48f0ff7 100644 --- a/lib/app/login_screen/login_screen.dart +++ b/lib/app/login_screen/login_screen.dart @@ -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 { try { final api = getIt(); + final preferencesRepository = getIt(); 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, ), ], diff --git a/lib/app/mw_app.dart b/lib/app/mw_app.dart index c2e0f2e..0f9f351 100644 --- a/lib/app/mw_app.dart +++ b/lib/app/mw_app.dart @@ -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 _bootstrapFuture; + + @override + void initState() { + super.initState(); + _bootstrapFuture = RestoreLauncherSessionUseCase( + launcherApiClient: getIt(), + preferencesRepository: getIt(), + )(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/launcher/application/restore_launcher_session_use_case.dart b/lib/features/launcher/application/restore_launcher_session_use_case.dart new file mode 100644 index 0000000..91ee7f5 --- /dev/null +++ b/lib/features/launcher/application/restore_launcher_session_use_case.dart @@ -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 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(); + } + } +} diff --git a/lib/features/launcher/domain/entities/launcher_session.dart b/lib/features/launcher/domain/entities/launcher_session.dart index fcc3284..d8d616b 100644 --- a/lib/features/launcher/domain/entities/launcher_session.dart +++ b/lib/features/launcher/domain/entities/launcher_session.dart @@ -17,6 +17,25 @@ final class LauncherSession { ); } + Map toJson() { + return { + '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; diff --git a/lib/features/preferences/data/repositories/shared_prefs_preferences_repository.dart b/lib/features/preferences/data/repositories/shared_prefs_preferences_repository.dart index a13f6ad..dff448a 100644 --- a/lib/features/preferences/data/repositories/shared_prefs_preferences_repository.dart +++ b/lib/features/preferences/data/repositories/shared_prefs_preferences_repository.dart @@ -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 setOutputDir(Uri uri) { return _preferences.setString(_outputDirKey, uri.toString()); } + + @override + Future 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 setLauncherSession(LauncherSession session) { + return _preferences.setString( + _launcherSessionKey, + jsonEncode(session.toJson()), + ); + } + + @override + Future clearLauncherSession() { + return _preferences.remove(_launcherSessionKey); + } } diff --git a/lib/features/preferences/domain/repositories/preferences_repository.dart b/lib/features/preferences/domain/repositories/preferences_repository.dart index 07d5eb4..5996783 100644 --- a/lib/features/preferences/domain/repositories/preferences_repository.dart +++ b/lib/features/preferences/domain/repositories/preferences_repository.dart @@ -1,5 +1,13 @@ +import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart'; + abstract interface class PreferencesRepository { Future getOutputDir(); Future setOutputDir(Uri uri); + + Future getLauncherSession(); + + Future setLauncherSession(LauncherSession session); + + Future clearLauncherSession(); } diff --git a/test/features/launcher/application/restore_launcher_session_use_case_test.dart b/test/features/launcher/application/restore_launcher_session_use_case_test.dart new file mode 100644 index 0000000..490e85c --- /dev/null +++ b/test/features/launcher/application/restore_launcher_session_use_case_test.dart @@ -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 fetchManifest(String accessToken) async { + fetchManifestCalls += 1; + return _manifest ?? + ClientManifest.fromJson({ + 'files': >[], + }); + } +} + +class _FakePreferencesRepository implements PreferencesRepository { + _FakePreferencesRepository({this.session}); + + LauncherSession? session; + bool clearedSession = false; + + @override + Future clearLauncherSession() async { + clearedSession = true; + session = null; + } + + @override + Future getLauncherSession() async => session; + + @override + Future getOutputDir() async => null; + + @override + Future setLauncherSession(LauncherSession session) async { + this.session = session; + } + + @override + Future setOutputDir(Uri uri) async {} +} diff --git a/test/features/preferences/data/repositories/shared_prefs_preferences_repository_test.dart b/test/features/preferences/data/repositories/shared_prefs_preferences_repository_test.dart new file mode 100644 index 0000000..de549d7 --- /dev/null +++ b/test/features/preferences/data/repositories/shared_prefs_preferences_repository_test.dart @@ -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({}); + }); + + 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); + }); + }); +} diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..9497129 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ