Initial commit
This commit is contained in:
@@ -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});
|
||||
}
|
||||
@@ -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: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)!,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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.8–3.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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}');
|
||||
});
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
Vendored
+13
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user