Initial commit

This commit is contained in:
gasaichandesu
2025-08-30 19:47:42 +04:00
commit 58d7808a8d
100 changed files with 6186 additions and 0 deletions
@@ -0,0 +1,114 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:rxdart/rxdart.dart';
part 'home_screen_event.dart';
part 'home_screen_state.dart';
const _downloadId = 'client';
class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
final DownloadManager _downloadManager;
final PreferencesRepository _preferencesRepository;
StreamSubscription<DownloadProgress>? _downloadProgressSubscription;
HomeScreenBloc({
required DownloadManager downloadManager,
required PreferencesRepository preferencesRepository,
}) : _downloadManager = downloadManager,
_preferencesRepository = preferencesRepository,
super(HomeScreenInitialState(model: HomeScreenModel.initial())) {
_setupHandlers();
add(HomeScreenLoad());
}
void _setupHandlers() {
on<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenDownloadRequested>(_onHomeScreenDownloadRequested);
on<HomeScreenDownloadPaused>((event, emit) {
// _downloadManager.pauseDownload();
// emit(HomeScreenDownloadPausedState());
});
on<HomeScreenDownloadProgressUpdated>(
_onHomeScreenDownloadProgressUpdated,
transformer: (events, mapper) =>
events.throttleTime(const Duration(seconds: 2)).switchMap(mapper),
);
}
Future<void> _onHomeScreenLoad(
HomeScreenLoad event,
Emitter<HomeScreenState> emit,
) async {
final outputDir = await _preferencesRepository.getOutputDir();
emit(
HomeScreenReadyToDownloadState(
model: state.model.copyWith(outputPath: outputDir),
),
);
}
Future<void> _onHomeScreenDownloadRequested(
HomeScreenDownloadRequested event,
Emitter<HomeScreenState> emit,
) async {
if (state.model.outputPath == null) {
final directory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Please select installation directory',
);
if (directory == null) {
return;
}
await _preferencesRepository.setOutputDir(Uri.directory(directory));
emit(
HomeScreenReadyToDownloadState(
model: state.model.copyWith(outputPath: Uri.directory(directory)),
),
);
}
_downloadProgressSubscription = _downloadManager
.start(
id: _downloadId,
request: DownloadRequest(
destinationPath: state.model.outputPath!.toFilePath(),
),
)
.listen((progress) => add(HomeScreenDownloadProgressUpdated(progress)));
}
void _onHomeScreenDownloadProgressUpdated(
HomeScreenDownloadProgressUpdated event,
Emitter<HomeScreenState> emit,
) {
emit(
HomeScreenDownloadingState(
model: state.model.copyWith(progress: event.progress),
),
);
}
@override
Future<void> close() async {
await _downloadProgressSubscription?.cancel();
await _downloadManager.cancel(_downloadId);
return super.close();
}
}
@@ -0,0 +1,16 @@
part of 'home_screen_bloc.dart';
@immutable
sealed class HomeScreenEvent {}
final class HomeScreenLoad extends HomeScreenEvent {}
final class HomeScreenDownloadRequested extends HomeScreenEvent {}
final class HomeScreenDownloadPaused extends HomeScreenEvent {}
final class HomeScreenDownloadProgressUpdated extends HomeScreenEvent {
final DownloadProgress progress;
HomeScreenDownloadProgressUpdated(this.progress);
}
@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
@immutable
final class HomeScreenModel {
/// The current download progress.
final DownloadProgress progress;
/// The output path for the downloaded file.
final Uri? outputPath;
const HomeScreenModel({required this.progress, this.outputPath});
const HomeScreenModel.initial()
: progress = const DownloadProgress.initial(),
outputPath = null;
HomeScreenModel copyWith({DownloadProgress? progress, Uri? outputPath}) {
return HomeScreenModel(
progress: progress ?? this.progress,
outputPath: outputPath ?? this.outputPath,
);
}
}
@@ -0,0 +1,24 @@
part of 'home_screen_bloc.dart';
@immutable
sealed class HomeScreenState {
final HomeScreenModel model;
const HomeScreenState({required this.model});
}
final class HomeScreenInitialState extends HomeScreenState {
const HomeScreenInitialState({required super.model});
}
final class HomeScreenReadyToDownloadState extends HomeScreenState {
const HomeScreenReadyToDownloadState({required super.model});
}
final class HomeScreenDownloadPausedState extends HomeScreenState {
const HomeScreenDownloadPausedState({required super.model});
}
final class HomeScreenDownloadingState extends HomeScreenState {
const HomeScreenDownloadingState({required super.model});
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter/material.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/left_pane.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider<HomeScreenBloc>(
create: (context) => HomeScreenBloc(
downloadManager: getIt<DownloadManager>(),
preferencesRepository: getIt<PreferencesRepository>(),
),
child: Builder(
builder: (context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
// subtle vignette for “night” vibe
gradient: RadialGradient(
center: Alignment(0, -0.5),
radius: 1.2,
colors: [MWColors.deepNavy, MWColors.abyss],
),
),
child: Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Row(
children: [
Expanded(
child: LeftPane(
title: Text(
'MoonWell',
style: theme.textTheme.headlineLarge,
),
progress: 0.0,
isDownloading: false,
kbps: 0.0,
eta: Duration.zero,
onStart: () {
context.read<HomeScreenBloc>().add(
HomeScreenDownloadRequested(),
);
},
onPause: () {},
onReset: () {},
),
),
],
),
),
),
),
);
},
),
);
}
}
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter/material.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/widgets/progress_bar.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:pixelarticons/pixel.dart';
class LeftPane extends StatelessWidget {
const LeftPane({
super.key,
required this.title,
required this.progress,
required this.isDownloading,
required this.kbps,
required this.eta,
required this.onStart,
required this.onPause,
required this.onReset,
});
final Widget title;
final double progress;
final bool isDownloading;
final double kbps;
final Duration eta;
final VoidCallback onStart;
final VoidCallback onPause;
final VoidCallback onReset;
String _speedLabel(BuildContext context) {
final kbps = context.read<HomeScreenBloc>().state.model.progress.speed;
if (kbps <= 0) return '';
if (kbps >= 1024) {
return '${(kbps / 1024).toStringAsFixed(2)} MB/s';
}
return '${kbps.toStringAsFixed(0)} kB/s';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return BlocBuilder<HomeScreenBloc, HomeScreenState>(
builder: (context, state) {
final percent = _resolvePercentage(context);
final eta = state.model.progress.eta;
final canPlay = progress >= 1.0 && state is! HomeScreenDownloadingState;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: 16),
Text(
'Classic+ Wrath of the Lich King experience',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: cs.onSurface.withAlpha(200),
),
),
const SizedBox(height: 28),
// Progress bar
GildedProgressBar(value: progress),
const SizedBox(height: 12),
Row(
children: [
Text(
'$percent%',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Icon(Pixel.speedfast, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 6),
Text(
_speedLabel(context),
style: TextStyle(color: cs.onSurfaceVariant),
),
const SizedBox(width: 16),
Icon(
Pixel.timeline, // Updated to use PixelArtIcons
size: 18,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
eta == Duration.zero
? ''
: '${eta.inMinutes}:${(eta.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(color: cs.onSurfaceVariant),
),
],
),
const SizedBox(height: 24),
// Controls row
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: Icon(
canPlay ? Icons.play_arrow_rounded : Icons.download,
),
label: Text(
canPlay
? 'Play'
: (isDownloading
? 'Downloading…'
: (progress == 0 ? 'Install' : 'Resume')),
),
onPressed: canPlay
? () {}
: (isDownloading ? null : onStart),
),
),
// const SizedBox(width: 12),
// OutlinedButton.icon(
// icon: Icon(
// isDownloading ? Icons.pause_rounded : Icons.replay,
// ),
// label: Text(isDownloading ? 'Pause' : 'Reset'),
// onPressed: isDownloading
// ? onPause
// : (progress > 0 ? onReset : null),
// ),
],
),
const SizedBox(height: 20),
// Disk path / build info (placeholders)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(128),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline),
boxShadow: (deco.cardGlow),
),
child: Row(
children: [
const Icon(Pixel.folder),
const SizedBox(width: 8),
Expanded(
child: Text(
'Install path: ${state.model.outputPath?.toFilePath() ?? ''}',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
],
),
),
],
),
),
);
},
);
}
String _resolvePercentage(BuildContext context) {
final state = context.read<HomeScreenBloc>().state;
if (state is HomeScreenDownloadingState) {
final percentage =
state.model.progress.downloaded / state.model.progress.total;
return (percentage * 100).clamp(0, 100).toStringAsFixed(1);
}
return '0.0';
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
class GildedProgressBar extends StatelessWidget {
const GildedProgressBar({required this.value});
final double value;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return Container(
height: 20,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: cs.outline),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Fill with gold bevel
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
alignment: Alignment.centerLeft,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: (deco.goldBevel as LinearGradient),
),
),
),
// Subtle top highlight
Align(
alignment: Alignment.topCenter,
child: Container(height: 6, color: Colors.white.withAlpha(10)),
),
],
),
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
class MoonWellApp extends StatelessWidget {
const MoonWellApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MoonWell',
home: const HomeScreen(),
theme: moonWellTheme(),
);
}
}
+25
View File
@@ -0,0 +1,25 @@
part of 'mw_theme.dart';
/// Core palette derived from the logo
class MWColors {
// “Night sky” blues
static const Color abyss = Color(0xFF0B101A); // page bg
static const Color deepNavy = Color(0xFF0F1522); // surfaces
static const Color stormNavy = Color(0xFF1A2233); // elevated surfaces
static const Color moonBlue = Color(
0xFF6BA3FF,
); // tertiary accent (moonlight)
// “Ornate gold”
static const Color gold = Color(0xFFE5B74A); // primary
static const Color goldDark = Color(0xFF7A5A00); // primary container
static const Color goldSoft = Color(0xFFF3D98C); // gradient highlight
// Lines & states
static const Color outline = Color(0xFF2B3242);
static const Color outlineGold = Color(0xFF7C6A3A);
// Semantic
static const Color success = Color(0xFF3DDC97);
static const Color warning = Color(0xFFF0B429);
}
+40
View File
@@ -0,0 +1,40 @@
part of 'mw_theme.dart';
class MoonWellDecorations extends ThemeExtension<MoonWellDecorations> {
final Gradient goldBevel; // for gilded headers/buttons
final Shadow textGlow; // subtle moonlight glow
final List<BoxShadow> cardGlow;
const MoonWellDecorations({
required this.goldBevel,
required this.textGlow,
required this.cardGlow,
});
@override
MoonWellDecorations copyWith({
Gradient? goldBevel,
Shadow? textGlow,
List<BoxShadow>? cardGlow,
}) => MoonWellDecorations(
goldBevel: goldBevel ?? this.goldBevel,
textGlow: textGlow ?? this.textGlow,
cardGlow: cardGlow ?? this.cardGlow,
);
@override
ThemeExtension<MoonWellDecorations> lerp(
ThemeExtension<MoonWellDecorations>? other,
double t,
) {
if (other is! MoonWellDecorations) return this;
return MoonWellDecorations(
goldBevel: Gradient.lerp(goldBevel, other.goldBevel, t)!,
textGlow: Shadow.lerp(textGlow, other.textGlow, t)!,
cardGlow: [
for (int i = 0; i < cardGlow.length; i++)
BoxShadow.lerp(cardGlow[i], other.cardGlow[i], t)!,
],
);
}
}
+222
View File
@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
part 'mw_colors.dart';
part 'mw_decorations.dart';
ThemeData moonWellTheme() {
const cs = ColorScheme(
brightness: Brightness.dark,
primary: MWColors.gold,
onPrimary: Color(0xFF1B1202),
primaryContainer: MWColors.goldDark,
onPrimaryContainer: Color(0xFFFFF0C2),
secondary: Color(0xFFC08A2E), // bronze accent
onSecondary: Color(0xFF201300),
secondaryContainer: Color(0xFF3B2A00),
onSecondaryContainer: Color(0xFFF6E2B6),
tertiary: MWColors.moonBlue, // moon-glow accent
onTertiary: Color(0xFF081120),
tertiaryContainer: Color(0xFF143A66),
onTertiaryContainer: Color(0xFFD9EBFF),
error: Color(0xFFFFB4AB),
onError: Color(0xFF690005),
errorContainer: Color(0xFF93000A),
onErrorContainer: Color(0xFFFFDAD6),
surface: MWColors.abyss,
onSurface: Color(0xFFE5EAF6),
surfaceContainerHighest: MWColors.stormNavy,
onSurfaceVariant: Color(0xFFC3C8D6),
outline: MWColors.outline,
outlineVariant: Color(0xFF40495C),
shadow: Colors.black,
scrim: Colors.black87,
inverseSurface: Color(0xFFE5EAF6),
onInverseSurface: Color(0xFF11151E),
inversePrimary: Color(0xFFF0D072),
);
final base = ThemeData(
useMaterial3: true,
colorScheme: cs,
scaffoldBackgroundColor: cs.surface,
canvasColor: cs.surface,
);
final text = base.textTheme
.apply(
fontFamily: 'Cinzel',
bodyColor: cs.onSurface,
displayColor: cs.onSurface,
)
.copyWith(
displayLarge: base.textTheme.displayLarge?.copyWith(
letterSpacing: 0.5,
fontWeight: FontWeight.w700,
shadows: const [Shadow(blurRadius: 10, color: Color(0x336BA3FF))],
),
headlineMedium: base.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
titleLarge: base.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
);
return base.copyWith(
textTheme: text,
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: cs.onSurface,
centerTitle: true,
titleTextStyle: text.titleLarge,
toolbarHeight: 64,
),
cardTheme: CardThemeData(
color: cs.surface,
elevation: 0,
margin: const EdgeInsets.all(12),
shape: RoundedRectangleBorder(
side: BorderSide(color: MWColors.outline.withAlpha(150)),
borderRadius: BorderRadius.circular(20),
),
shadowColor: MWColors.moonBlue.withAlpha(63),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 18, vertical: 14),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
elevation: const WidgetStatePropertyAll(6),
shadowColor: WidgetStatePropertyAll(MWColors.moonBlue.withAlpha(89)),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return MWColors.gold.withAlpha(115);
}
return cs.primary;
}),
foregroundColor: const WidgetStatePropertyAll(
Color(0xFF1B1202),
), // dark text on gold
),
),
textButtonTheme: TextButtonThemeData(
style: ButtonStyle(
foregroundColor: WidgetStatePropertyAll(cs.tertiary),
overlayColor: WidgetStatePropertyAll(cs.tertiary.withAlpha(10)),
),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(cs.tertiaryContainer),
foregroundColor: WidgetStatePropertyAll(cs.onTertiaryContainer),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
side: WidgetStatePropertyAll(
BorderSide(color: MWColors.outlineGold.withAlpha(230)),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
foregroundColor: WidgetStatePropertyAll(cs.primary),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: cs.surfaceContainerHighest,
hintStyle: TextStyle(color: cs.onSurfaceVariant.withAlpha(179)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: MWColors.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: cs.primary, width: 1.6),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: cs.error),
),
prefixIconColor: cs.onSurfaceVariant,
suffixIconColor: cs.onSurfaceVariant,
),
chipTheme: base.chipTheme.copyWith(
backgroundColor: cs.surfaceContainerHighest,
side: BorderSide(color: MWColors.outline),
selectedColor: cs.primaryContainer,
labelStyle: TextStyle(color: cs.onSurface),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
sliderTheme: base.sliderTheme.copyWith(
activeTrackColor: cs.primary,
inactiveTrackColor: cs.primary.withAlpha(63),
thumbColor: cs.primary,
),
dividerTheme: DividerThemeData(
color: MWColors.outline,
thickness: 1,
space: 24,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: cs.surface,
selectedItemColor: cs.primary,
unselectedItemColor: cs.onSurface.withAlpha(153),
elevation: 8,
type: BottomNavigationBarType.fixed,
),
extensions: <ThemeExtension<dynamic>>[
const MoonWellDecorations(
goldBevel: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
MWColors.goldSoft, // highlight
MWColors.gold, // body
Color(0xFFC58A1E), // warm edge
],
stops: [0.0, 0.55, 1.0],
),
textGlow: Shadow(
color: Color(0x446BA3FF), // moon-glow
blurRadius: 14,
offset: Offset(0, 0),
),
cardGlow: [
BoxShadow(
color: Color(0x33143666), // cool rim light
blurRadius: 28,
spreadRadius: 2,
offset: Offset(0, 8),
),
],
),
],
);
}
+10
View File
@@ -0,0 +1,10 @@
final class Config {
static const host = 'storage.yandexcloud.net';
static const bucketName = 'warcraft-client';
static const id = 'ajeoijp637jj3527jd96';
static const serviceAccountId = 'ajerr0qmscj6eof68buc';
static const createdAt = "2025-08-24T12:08:22.166354325Z";
static const keyId = 'YCAJEwIWaVVRCAx2YM2XMqcCS';
static const secret = '';
static const objectName = 'World of Warcraft.zip';
}
+18
View File
@@ -0,0 +1,18 @@
/// A use case represents a single unit of work in the application.
abstract interface class UseCase<Input, Output> {
Output call(Input input);
}
/// A use case that returns a Future of [Output].
abstract interface class FutureUseCase<Input, Output>
implements UseCase<Input, Future<Output>> {
@override
Future<Output> call(Input input);
}
/// A use case that returns a Stream of [Output].
abstract interface class StreamUseCase<Input, Output>
implements UseCase<Input, Stream<Output>> {
@override
Stream<Output> call(Input input);
}
@@ -0,0 +1,106 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/features/downloader/use_cases/download_object_use_case.dart';
typedef DownloadId = String;
class _Session {
_Session({required this.id, required this.request, required this.useCase})
: progressController = StreamController<DownloadProgress>.broadcast();
final DownloadId id;
final DownloadRequest request;
final DownloadObjectUseCase useCase;
final StreamController<DownloadProgress> progressController;
StreamSubscription<DownloadProgress>? sub;
bool paused = false;
bool cancelled = false;
Stream<DownloadProgress> get stream => progressController.stream;
Future<void> start() async {
cancelled = false;
paused = false;
sub = useCase
.call(
DownloadObjectUseCaseInput(
request: request,
isCancelled: () async => cancelled || paused,
),
)
.listen(
progressController.add,
onError: progressController.addError,
onDone: () => progressController.close(),
);
}
Future<void> pause() async {
paused = true;
await sub?.cancel();
sub = null;
}
Future<void> resume() async {
if (cancelled) return;
paused = false;
await start(); // will resume from file size
}
Future<void> cancel() async {
cancelled = true;
await sub?.cancel();
sub = null;
if (!progressController.isClosed) {
progressController.addError(const CancelledException());
await progressController.close();
}
}
}
@lazySingleton
class DownloadManager {
DownloadManager(this._useCase);
final DownloadObjectUseCase _useCase;
final Map<DownloadId, _Session> _sessions = {};
/// Start or replace a session with [id].
Stream<DownloadProgress> start({
required DownloadId id,
required DownloadRequest request,
}) {
// dispose old session if exists
_sessions[id]?.cancel();
final s = _Session(id: id, request: request, useCase: _useCase);
_sessions[id] = s;
unawaited(s.start());
return s.stream;
}
Stream<DownloadProgress>? progress(DownloadId id) => _sessions[id]?.stream;
Future<void> pause(DownloadId id) => _sessions[id]?.pause() ?? Future.value();
Future<void> resume(DownloadId id) =>
_sessions[id]?.resume() ?? Future.value();
Future<void> cancel(DownloadId id) async {
await _sessions[id]?.cancel();
_sessions.remove(id);
}
bool isActive(DownloadId id) => _sessions.containsKey(id);
Iterable<DownloadId> activeIds() => _sessions.keys;
/// Cancel and clear all sessions (e.g., on sign-out).
Future<void> cancelAll() async {
for (final s in _sessions.values) {
await s.cancel();
}
_sessions.clear();
}
}
@@ -0,0 +1,64 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart' as c;
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
@LazySingleton(as: FileSystem)
class IoFileSystem implements FileSystem {
@override
Future<void> ensureParentExists(String path) =>
File(path).parent.create(recursive: true);
@override
Future<bool> exists(String path) => File(path).exists();
@override
Future<int> sizeOf(String path) async => (await File(path).stat()).size;
@override
Future<void> truncate(String path) async =>
File(path).writeAsBytes(const [], flush: true);
@override
Future<void> createEmpty(String path) => File(path).create(recursive: true);
@override
Future<StreamSink<List<int>>> openAppend(String path) async =>
File(path).openWrite(mode: FileMode.append);
@override
Future<bool> verifyFile(
String path, {
required String expected,
required String algo,
}) async {
final file = File(path);
if (!await file.exists()) return false;
final digestHex = await _computeDigestHex(file, algo);
return _constantTimeEquals(digestHex.toLowerCase(), expected.toLowerCase());
}
Future<String> _computeDigestHex(File file, String algo) async {
final hash = switch (algo.toLowerCase()) {
'md5' => c.md5,
'sha256' => c.sha256,
_ => throw ArgumentError('Unsupported checksum algo: $algo'),
};
// Stream the file into the hash converter; no extra buffers.
final digest = await hash.bind(file.openRead()).first; // -> c.Digest
return digest.toString(); // lowercase hex
}
bool _constantTimeEquals(String a, String b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) {
result |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
}
return result == 0;
}
}
@@ -0,0 +1,30 @@
import 'package:injectable/injectable.dart';
import 'package:minio/minio.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
/// MinIO storage reader implementation.
@LazySingleton(as: StorageReader)
class MinioStorageReader implements StorageReader {
final Minio _client;
MinioStorageReader(this._client);
@override
Future<Stream<List<int>>> readFromOffset({
required String bucket,
required String key,
required int startOffset,
}) {
return _client.getPartialObject(bucket, key, startOffset);
}
@override
Future<RemoteObjectMeta> stat({
required String bucket,
required String key,
}) async {
final s = await _client.statObject(bucket, key);
return RemoteObjectMeta(size: s.size ?? 0, eTag: s.etag ?? '');
}
}
@@ -0,0 +1,24 @@
sealed class DownloadException implements Exception {
final String message;
const DownloadException(this.message);
@override
String toString() => '$runtimeType: $message';
}
final class NetworkException extends DownloadException {
const NetworkException(super.message);
}
final class RemoteException extends DownloadException {
const RemoteException(super.message);
}
final class IoException extends DownloadException {
const IoException(super.message);
}
final class CancelledException extends DownloadException {
const CancelledException() : super('Cancelled');
}
@@ -0,0 +1,27 @@
/// Represents the state of a download.
final class DownloadProgress {
/// Download speed in bytes per second.
final int speed;
/// Total bytes downloaded so far.
final int downloaded;
/// Total bytes to download.
final int total;
/// Estimated time of arrival (ETA) for the download to complete.
final Duration eta;
const DownloadProgress({
required this.speed,
required this.downloaded,
required this.total,
required this.eta,
});
const DownloadProgress.initial()
: speed = 0,
downloaded = 0,
total = 0,
eta = Duration.zero;
}
@@ -0,0 +1,7 @@
/// Represents a request to download an object from a storage bucket.
class DownloadRequest {
/// Local file path to save the downloaded object
final String destinationPath;
const DownloadRequest({required this.destinationPath});
}
@@ -0,0 +1,17 @@
/// Represents the result of a download operation.
class DownloadResult {
/// Local file path where the downloaded object is stored.
final String path;
/// Number of bytes downloaded.
final int bytes;
/// Entity tag (ETag) of the downloaded object.
final String eTag;
const DownloadResult({
required this.path,
required this.bytes,
required this.eTag,
});
}
@@ -0,0 +1,10 @@
/// Represents metadata for a remote object in a storage bucket.
class RemoteObjectMeta {
/// Size of the object in bytes.
final int size;
/// Entity tag (ETag) of the object.
final String eTag;
const RemoteObjectMeta({required this.size, required this.eTag});
}
@@ -0,0 +1,29 @@
import 'dart:async';
/// Interface for interacting with the file system.
abstract interface class FileSystem {
/// Ensures that the parent directory of the given path exists.
Future<void> ensureParentExists(String path);
/// Checks if a file or directory exists at the given path.
Future<bool> exists(String path);
/// Returns the size of the file at the given path.
Future<int> sizeOf(String path);
/// Truncates the file at the given path to zero length.
Future<void> truncate(String path);
/// Creates an empty file at the given path.
Future<void> createEmpty(String path);
/// Opens a stream sink for appending data to the file at the given path.
Future<StreamSink<List<int>>> openAppend(String path);
/// Verifies the integrity of a file at the given path.
Future<bool> verifyFile(
String path, {
required String expected,
required String algo,
});
}
@@ -0,0 +1,14 @@
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
/// Interface for reading data from storage.
abstract interface class StorageReader {
/// Retrieves metadata about a remote object.
Future<RemoteObjectMeta> stat({required String bucket, required String key});
/// Reads data from a remote object starting at the specified offset.
Future<Stream<List<int>>> readFromOffset({
required String bucket,
required String key,
required int startOffset,
});
}
@@ -0,0 +1,160 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_result.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
final class DownloadObjectUseCaseInput {
final DownloadRequest request;
final FutureOr<bool> Function()? isCancelled;
final void Function(DownloadResult result)? onComplete;
const DownloadObjectUseCaseInput({
required this.request,
this.isCancelled,
this.onComplete,
});
}
@lazySingleton
class DownloadObjectUseCase
implements StreamUseCase<DownloadObjectUseCaseInput, DownloadProgress> {
final StorageReader _storage;
final FileSystem _fileSystem;
const DownloadObjectUseCase({
required StorageReader storage,
required FileSystem fileSystem,
}) : _storage = storage,
_fileSystem = fileSystem;
@override
Stream<DownloadProgress> call(DownloadObjectUseCaseInput input) async* {
final request = input.request;
final meta = await _storage.stat(
bucket: Config.bucketName,
key: Config.objectName,
);
final total = meta.size;
final pathToFile = '${request.destinationPath}/${Config.objectName}';
await _fileSystem.ensureParentExists(pathToFile);
int offset = 0;
if (await _fileSystem.exists(pathToFile)) {
offset = await _fileSystem.sizeOf(pathToFile);
if (offset > total) {
await _fileSystem.truncate(pathToFile);
offset = 0;
}
if (offset == total) {
input.onComplete?.call(
DownloadResult(path: pathToFile, bytes: offset, eTag: meta.eTag),
);
return;
}
} else {
await _fileSystem.createEmpty(pathToFile);
}
final objectStream = await _storage.readFromOffset(
bucket: Config.bucketName,
key: Config.objectName,
startOffset: offset,
);
final sink = await _fileSystem.openAppend(pathToFile);
final controller = StreamController<DownloadProgress>();
final sw = Stopwatch()..start();
int lastBytes = 0;
int downloaded = offset;
Timer? ticker;
void tick() {
final elapsedMs = sw.elapsedMilliseconds;
final bps = elapsedMs == 0 ? 0 : (lastBytes * 1000) ~/ elapsedMs;
final remaining = (total - downloaded).clamp(0, total);
final eta = bps > 0 ? Duration(seconds: remaining ~/ bps) : Duration.zero;
controller.add(
DownloadProgress(
downloaded: downloaded,
total: total,
speed: bps,
eta: eta,
),
);
lastBytes = 0;
sw
..reset()
..start();
}
ticker = Timer.periodic(const Duration(seconds: 1), (_) => tick());
late StreamSubscription<List<int>> sub;
controller.onCancel = () async {
await sub.cancel();
await sink.close();
ticker?.cancel();
};
sub = objectStream.listen(
(chunk) async {
if (input.isCancelled != null && await input.isCancelled!.call()) {
ticker?.cancel();
await sub.cancel();
await sink.close();
controller.addError(const CancelledException());
await controller.close();
return;
}
sink.add(chunk);
final n = chunk.length;
downloaded += n;
lastBytes += n;
},
onError: (e, st) async {
ticker?.cancel();
await sink.close();
await controller.close();
throw _mapError(e);
},
onDone: () async {
ticker?.cancel();
tick(); // final snapshot
await sink.close();
await controller.close();
input.onComplete?.call(
DownloadResult(path: pathToFile, bytes: downloaded, eTag: meta.eTag),
);
},
cancelOnError: true,
);
yield* controller.stream;
}
DownloadException _mapError(Object e) {
final s = e.toString();
if (s.contains('SocketException') || s.contains('HandshakeException')) {
return NetworkException(s);
}
if (s.contains('Minio')) return RemoteException(s);
return IoException(s);
}
}
@@ -0,0 +1,30 @@
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _outputDirKey = 'output_dir';
@LazySingleton(as: PreferencesRepository, env: ['flutter'])
class SharedPrefsPreferencesRepository implements PreferencesRepository {
final SharedPreferences _preferences;
const SharedPrefsPreferencesRepository({
required SharedPreferences preferences,
}) : _preferences = preferences;
@override
Future<Uri?> getOutputDir() {
final rawPath = _preferences.getString(_outputDirKey);
if (rawPath == null) {
return Future.value(null);
}
return Future.value(Uri.parse(rawPath));
}
@override
Future<void> setOutputDir(Uri uri) {
return _preferences.setString(_outputDirKey, uri.toString());
}
}
@@ -0,0 +1,5 @@
abstract interface class PreferencesRepository {
Future<Uri?> getOutputDir();
Future<void> setOutputDir(Uri uri);
}
+449
View File
@@ -0,0 +1,449 @@
// main.dart
import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
import 'package:moonwell_launcher/app/mw_app.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/service_container.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies(env: 'flutter');
runApp(const MoonWellApp());
}
bool get _isDesktop => const {
TargetPlatform.macOS,
TargetPlatform.linux,
TargetPlatform.windows,
}.contains(defaultTargetPlatform);
class LauncherHome extends StatefulWidget {
const LauncherHome({super.key});
@override
State<LauncherHome> createState() => _LauncherHomeState();
}
class _LauncherHomeState extends State<LauncherHome> {
double _progress = 0.0; // 0..1
bool _isDownloading = false;
double _kbps = 0; // fake speed
Duration _eta = Duration.zero;
Timer? _timer;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startDownload() {
if (_isDownloading || _progress >= 1.0) return;
setState(() => _isDownloading = true);
_timer = Timer.periodic(const Duration(milliseconds: 160), (t) {
// Fake speed & progress for demo
final chunk = 0.002 + math.Random().nextDouble() * 0.004;
_kbps = 800 + math.Random().nextDouble() * 2200; // 0.83.0 MB/s
_progress = (_progress + chunk).clamp(0.0, 1.0);
final remaining = (1 - _progress);
// naive ETA based on current "speed"
_eta = Duration(seconds: math.max(1, (remaining * 120).round()));
if (_progress >= 1.0) {
_isDownloading = false;
t.cancel();
}
setState(() {});
});
}
void _pause() {
_timer?.cancel();
setState(() => _isDownloading = false);
}
void _reset() {
_timer?.cancel();
setState(() {
_progress = 0;
_isDownloading = false;
_kbps = 0;
_eta = Duration.zero;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
final title = ShaderMask(
shaderCallback: (rect) =>
(deco.goldBevel as LinearGradient).createShader(rect),
child: Text(
'MOONWELL',
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(shadows: [deco.textGlow]),
),
);
if (!_isDesktop) {
return Scaffold(
body: Center(
child: Text(
'Desktop only. Please run on Windows, macOS, or Linux.',
style: Theme.of(context).textTheme.titleLarge,
),
),
);
}
return Scaffold(
body: Container(
decoration: const BoxDecoration(
// subtle vignette for night vibe
gradient: RadialGradient(
center: Alignment(0, -0.5),
radius: 1.2,
colors: [Color(0xFF0F1522), Color(0xFF0B101A)],
),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 760),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
// LEFT: Game title + download controls
Expanded(
flex: 3,
child: _LeftPane(
title: title,
progress: _progress,
isDownloading: _isDownloading,
kbps: _kbps,
eta: _eta,
onStart: _startDownload,
onPause: _pause,
onReset: _reset,
),
),
const SizedBox(width: 20),
// RIGHT: Patch notes
Expanded(flex: 4, child: _PatchNotesPane(color: cs.surface)),
],
),
),
),
),
),
);
}
}
class _LeftPane extends StatelessWidget {
const _LeftPane({
required this.title,
required this.progress,
required this.isDownloading,
required this.kbps,
required this.eta,
required this.onStart,
required this.onPause,
required this.onReset,
});
final Widget title;
final double progress;
final bool isDownloading;
final double kbps;
final Duration eta;
final VoidCallback onStart;
final VoidCallback onPause;
final VoidCallback onReset;
String get _speedLabel {
if (kbps <= 0) return '';
if (kbps >= 1024) {
return '${(kbps / 1024).toStringAsFixed(2)} MB/s';
}
return '${kbps.toStringAsFixed(0)} kB/s';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
final percent = (progress * 100).clamp(0, 100).toStringAsFixed(1);
final canPlay = progress >= 1.0 && !isDownloading;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: 16),
Text(
'Classic MMO Launcher',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: cs.onSurface.withOpacity(0.85),
),
),
const SizedBox(height: 28),
// Progress bar
_GildedProgressBar(value: progress),
const SizedBox(height: 12),
Row(
children: [
Text(
'$percent%',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Icon(Icons.speed, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 6),
Text(_speedLabel, style: TextStyle(color: cs.onSurfaceVariant)),
const SizedBox(width: 16),
Icon(
Icons.timer_outlined,
size: 18,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
eta == Duration.zero
? ''
: '${eta.inMinutes}:${(eta.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(color: cs.onSurfaceVariant),
),
],
),
const SizedBox(height: 24),
// Controls row
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: Icon(
canPlay ? Icons.play_arrow_rounded : Icons.download,
),
label: Text(
canPlay
? 'Play'
: (isDownloading
? 'Downloading…'
: (progress == 0 ? 'Install' : 'Resume')),
),
onPressed: canPlay
? () {}
: (isDownloading ? null : onStart),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
icon: Icon(
isDownloading ? Icons.pause_rounded : Icons.replay,
),
label: Text(isDownloading ? 'Pause' : 'Reset'),
onPressed: isDownloading
? onPause
: (progress > 0 ? onReset : null),
),
],
),
const SizedBox(height: 20),
// Disk path / build info (placeholders)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline),
boxShadow: (deco.cardGlow),
),
child: Row(
children: [
const Icon(Icons.folder_open),
const SizedBox(width: 8),
Expanded(
child: Text(
'Install path: C:/Games/MoonWell',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 6),
Text(
'Build: 1.2.0 (live)',
style: TextStyle(color: cs.onSurfaceVariant),
),
],
),
),
],
),
),
);
}
}
class _GildedProgressBar extends StatelessWidget {
const _GildedProgressBar({required this.value});
final double value;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
return Container(
height: 20,
decoration: BoxDecoration(
color: cs.surfaceVariant,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: cs.outline),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Fill with gold bevel
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
alignment: Alignment.centerLeft,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: (deco.goldBevel as LinearGradient),
),
),
),
// Subtle top highlight
Align(
alignment: Alignment.topCenter,
child: Container(height: 6, color: Colors.white.withOpacity(0.08)),
),
],
),
);
}
}
class _PatchNotesPane extends StatelessWidget {
const _PatchNotesPane({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// Example static notes wire your real source here.
final notes = [
(
'1.2.0 — “Moonlit March”',
[
'New dungeon: *Hall of Tides*.',
'Launcher supports delta updates with blockmaps.',
'Improved shader warmup (faster first launch).',
'Fixed login race condition on slow networks.',
],
),
(
'1.1.5',
[
'Balance pass on Ranger & Warlock.',
'Memory usage reduced ~12% in large towns.',
],
),
];
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
border: Border(bottom: BorderSide(color: cs.outline)),
),
child: Row(
children: [
const Icon(Icons.article_outlined),
const SizedBox(width: 10),
Text(
'Patch Notes',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
TextButton.icon(
onPressed: () {}, // hook to "Open full changelog"
icon: const Icon(Icons.open_in_new),
label: const Text('Full changelog'),
),
],
),
),
// Body
Expanded(
child: Scrollbar(
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
itemCount: notes.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, i) {
final (title, items) = notes[i];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...items.map(
(s) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(''),
Expanded(
child: Text(
s,
style: TextStyle(
color: cs.onSurface.withOpacity(0.9),
),
),
),
],
),
),
],
);
},
),
),
),
],
),
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:async';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/service_container.dart';
final _log = Logger('Downloader');
Future<void> main(List<String> arguments) async {
await configureDependencies();
_setupLogging();
final manager = getIt<DownloadManager>();
// Start the download and keep a handle to the subscription
final stream = manager.start(
id: 'client',
request: DownloadRequest(
destinationPath: '/Users/kalistratovm/Games/MoonWell/client.zip',
),
);
final done = Completer<int>(); // exit code completer
late StreamSubscription sub;
// Graceful shutdown on Ctrl-C / SIGTERM
final sigintSub = ProcessSignal.sigint.watch();
sigintSub.listen((_) async {
_log.warning('SIGINT received. Cancelling all downloads…');
await manager.cancelAll();
await sub.cancel();
if (!done.isCompleted) {
done.complete(130); // 130 = terminated by Ctrl-C
}
});
final sigtermSub = ProcessSignal.sigterm.watch().listen((_) async {
_log.warning('SIGTERM received. Cancelling all downloads…');
await manager.cancelAll();
await sub.cancel();
done.complete(143); // 143 = terminated by SIGTERM
});
sub = stream.listen(
(p) {
_log.info('Speed: ${p.speed}');
_log.info('ETA: ${p.eta}');
_log.info('Done: ${p.downloaded}/${p.total}');
},
onError: (e, st) async {
_log.severe('Download error', e, st);
await sigtermSub.cancel();
done.complete(1);
},
onDone: () async {
_log.info('Download completed');
await sigtermSub.cancel();
done.complete(0);
},
cancelOnError: true,
);
// Keep process alive until one of the completions above fires
final code = await done.future;
exit(code);
}
void _setupLogging() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((rec) {
final color = switch (rec.level) {
Level.SEVERE || Level.SHOUT => '\x1B[31m',
Level.WARNING => '\x1B[33m',
Level.INFO => '\x1B[36m',
_ => '\x1B[90m',
};
stdout.writeln(
'$color[${rec.time.toIso8601String()}] '
'${rec.level.name.padRight(7)} '
'${rec.loggerName}: ${rec.message}\x1B[0m',
);
if (rec.error != null) stdout.writeln(' Error: ${rec.error}');
if (rec.stackTrace != null) stdout.writeln(' Stack: ${rec.stackTrace}');
});
}
+71
View File
@@ -0,0 +1,71 @@
// dart format width=80
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// InjectableConfigGenerator
// **************************************************************************
// ignore_for_file: type=lint
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:get_it/get_it.dart' as _i174;
import 'package:injectable/injectable.dart' as _i526;
import 'package:minio/minio.dart' as _i875;
import 'package:shared_preferences/shared_preferences.dart' as _i460;
import 'features/downloader/application/download_manager.dart' as _i535;
import 'features/downloader/data/io_file_system.dart' as _i173;
import 'features/downloader/data/minio_storage_reader.dart' as _i980;
import 'features/downloader/domain/repositories/file_system.dart' as _i843;
import 'features/downloader/domain/repositories/storage_reader.dart' as _i839;
import 'features/downloader/use_cases/download_object_use_case.dart' as _i925;
import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart'
as _i662;
import 'features/preferences/domain/repositories/preferences_repository.dart'
as _i44;
import 'third_party/minio.dart' as _i285;
import 'third_party/shared_preferences.dart' as _i1006;
const String _flutter = 'flutter';
extension GetItInjectableX on _i174.GetIt {
// initializes the registration of main-scope dependencies inside of GetIt
Future<_i174.GetIt> init({
String? environment,
_i526.EnvironmentFilter? environmentFilter,
}) async {
final gh = _i526.GetItHelper(this, environment, environmentFilter);
final sharedPreferencesModule = _$SharedPreferencesModule();
final minioModule = _$MinioModule();
await gh.factoryAsync<_i460.SharedPreferences>(
() => sharedPreferencesModule.prefs,
preResolve: true,
);
gh.lazySingleton<_i875.Minio>(() => minioModule.minioClient);
gh.lazySingleton<_i839.StorageReader>(
() => _i980.MinioStorageReader(gh<_i875.Minio>()),
);
gh.lazySingleton<_i843.FileSystem>(() => _i173.IoFileSystem());
gh.lazySingleton<_i44.PreferencesRepository>(
() => _i662.SharedPrefsPreferencesRepository(
preferences: gh<_i460.SharedPreferences>(),
),
registerFor: {_flutter},
);
gh.lazySingleton<_i925.DownloadObjectUseCase>(
() => _i925.DownloadObjectUseCase(
storage: gh<_i839.StorageReader>(),
fileSystem: gh<_i843.FileSystem>(),
),
);
gh.lazySingleton<_i535.DownloadManager>(
() => _i535.DownloadManager(gh<_i925.DownloadObjectUseCase>()),
);
return this;
}
}
class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {}
class _$MinioModule extends _i285.MinioModule {}
+14
View File
@@ -0,0 +1,14 @@
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'service_container.config.dart';
final getIt = GetIt.instance;
@InjectableInit(
initializerName: 'init',
preferRelativeImports: true,
asExtension: true,
)
Future<void> configureDependencies({String? env}) =>
getIt.init(environment: env);
+13
View File
@@ -0,0 +1,13 @@
import 'package:injectable/injectable.dart';
import 'package:minio/minio.dart';
import 'package:moonwell_launcher/config.dart';
@module
abstract class MinioModule {
@lazySingleton
Minio get minioClient => Minio(
endPoint: Config.host,
accessKey: Config.keyId,
secretKey: Config.secret,
);
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
@module
abstract class SharedPreferencesModule {
@preResolve
Future<SharedPreferences> get prefs async =>
await SharedPreferences.getInstance();
}