refactor(bootstrapper): UI redesign
This commit is contained in:
@@ -52,9 +52,15 @@ void main() async {
|
||||
completer.future.then((_) {
|
||||
talker.info("Bootstrapper completed, launching app");
|
||||
});
|
||||
|
||||
var ourTheme = commonTheme.copyWith(
|
||||
scaffoldBackgroundColor: Colors.white,
|
||||
colorScheme: ColorScheme.dark(surface: Colors.blueGrey),
|
||||
);
|
||||
|
||||
runApp(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark(useMaterial3: true),
|
||||
theme: ourTheme,
|
||||
home: ProviderScope(
|
||||
child: Bootstrapper(
|
||||
stages: [SimpleStage(), SimpleStage2()],
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// ignore_for_file: annotate_overrides
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -12,22 +15,28 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
part 'bootstrapper.g.dart';
|
||||
part 'bootstrapper.freezed.dart';
|
||||
|
||||
/// A single bootstrap stage.
|
||||
/// Implementations should report progress/title updates via [StageController].
|
||||
abstract class StageFactory {
|
||||
String get title;
|
||||
|
||||
/// Optional: if your stage can be skipped when already completed.
|
||||
Future<bool> get isAlreadyCompleted async => false;
|
||||
|
||||
Future<void> start(StageController controller);
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
/// Bootstrap progress: either definite (0..1) or indefinite.
|
||||
@freezed
|
||||
sealed class Progress with _$Progress {
|
||||
const factory Progress.definite(double value) = PercentageProgress;
|
||||
const factory Progress.indefinite() = IndefiniteProgress;
|
||||
}
|
||||
|
||||
class StageState {
|
||||
@freezed
|
||||
class StageState with _$StageState {
|
||||
String title;
|
||||
Progress progress;
|
||||
|
||||
@@ -39,20 +48,20 @@ typedef Stages = List<StageFactory>;
|
||||
@riverpod
|
||||
class CurrentStage extends _$CurrentStage {
|
||||
@override
|
||||
StageState? build() {
|
||||
return null;
|
||||
}
|
||||
StageState? build() => null;
|
||||
|
||||
void set(StageState newState) {
|
||||
state = newState;
|
||||
}
|
||||
void set(StageState newState) => state = newState;
|
||||
|
||||
void setProgress(Progress progress) {
|
||||
state?.progress = progress;
|
||||
final s = state;
|
||||
if (s == null) return;
|
||||
state = s.copyWith(progress: progress);
|
||||
}
|
||||
|
||||
void setTitle(String title) {
|
||||
state?.title = title;
|
||||
final s = state;
|
||||
if (s == null) return;
|
||||
state = s.copyWith(title: title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +78,9 @@ class StageController {
|
||||
|
||||
void setIndefiniteProgress() {
|
||||
if (!_ref.mounted) return;
|
||||
_ref.read(currentStageProvider.notifier).setProgress(Progress.indefinite());
|
||||
_ref
|
||||
.read(currentStageProvider.notifier)
|
||||
.setProgress(const Progress.indefinite());
|
||||
}
|
||||
|
||||
void updateTitle(String title) {
|
||||
@@ -82,29 +93,37 @@ class StageController {
|
||||
class StagesWorker extends _$StagesWorker {
|
||||
bool _hasStarted = false;
|
||||
|
||||
Future<bool> build() async {
|
||||
return false;
|
||||
}
|
||||
@override
|
||||
Future<bool> build() async => false;
|
||||
|
||||
void start(Stages stages) async {
|
||||
Future<void> start(Stages stages) async {
|
||||
if (_hasStarted) return;
|
||||
_hasStarted = true;
|
||||
|
||||
state = AsyncValue.loading();
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
for (final stage in stages) {
|
||||
talker.info("${stage.title} starting");
|
||||
|
||||
// Optional skip.
|
||||
final alreadyDone = await stage.isAlreadyCompleted;
|
||||
if (alreadyDone) {
|
||||
talker.info("${stage.title} skipped (already completed)");
|
||||
continue;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(currentStageProvider.notifier)
|
||||
.set(
|
||||
StageState(title: stage.title, progress: Progress.indefinite()),
|
||||
StageState(
|
||||
title: stage.title,
|
||||
progress: const Progress.indefinite(),
|
||||
),
|
||||
);
|
||||
|
||||
await stage.start(StageController(ref));
|
||||
talker.info("${stage.title} completed");
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -112,32 +131,120 @@ class StagesWorker extends _$StagesWorker {
|
||||
|
||||
class _DefiniteIndicator extends StatelessWidget {
|
||||
final double progress; // 0.0 to 1.0
|
||||
|
||||
const _DefiniteIndicator({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final value = progress.clamp(0.0, 1.0);
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final side = (c.hasBoundedWidth && c.hasBoundedHeight)
|
||||
? (c.maxWidth < c.maxHeight ? c.maxWidth : c.maxHeight)
|
||||
: 80.0; // sensible fallback if unconstrained
|
||||
// Make it adaptive and square to prevent overflow in flexible layouts.
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final side = c.maxWidth.isFinite ? c.maxWidth : 120.0;
|
||||
final radius = side * 0.42; // padding around ring
|
||||
final stroke = (side * 0.08).clamp(4.0, 10.0);
|
||||
|
||||
final radius = side / 2;
|
||||
final value = progress.clamp(0.0, 1.0);
|
||||
final formattedProgress = "${(value * 100).toStringAsFixed(0)}%";
|
||||
|
||||
final formattedProgress = "${(progress * 100).toStringAsFixed(0)}%";
|
||||
return CircularPercentIndicator(
|
||||
radius: radius * 2,
|
||||
lineWidth: 5.0,
|
||||
percent: value,
|
||||
center: Text(formattedProgress),
|
||||
progressColor: Theme.of(context).colorScheme.secondary,
|
||||
);
|
||||
},
|
||||
return Center(
|
||||
child: CircularPercentIndicator(
|
||||
radius: radius,
|
||||
lineWidth: stroke,
|
||||
percent: value,
|
||||
center: Text(
|
||||
formattedProgress,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
circularStrokeCap: CircularStrokeCap.round,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
progressColor: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// return CircularProgressIndicator(value: value);
|
||||
class _IndefiniteIndicator extends StatelessWidget {
|
||||
const _IndefiniteIndicator();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final side = c.maxWidth.isFinite ? c.maxWidth : 120.0;
|
||||
final size = side * 0.55;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: SpinKitRing(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
lineWidth: (side * 0.06).clamp(3.0, 8.0),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FrostCard extends StatelessWidget {
|
||||
const _FrostCard({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: cs.onSurface.withValues(alpha: 0.10),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackgroundGlow extends StatelessWidget {
|
||||
const _BackgroundGlow();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: const Alignment(0, -0.55),
|
||||
radius: 1.25,
|
||||
colors: [
|
||||
cs.primary.withValues(alpha: 0.18),
|
||||
cs.secondary.withValues(alpha: 0.10),
|
||||
Colors.black,
|
||||
],
|
||||
stops: const [0.0, 0.45, 1.0],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,31 +253,60 @@ class _BootstrapFooter extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(currentStageProvider);
|
||||
if (state == null) return Container();
|
||||
final stage = ref.watch(currentStageProvider);
|
||||
|
||||
final progressIndicator = state.progress.when(
|
||||
final title = stage?.title ?? "Preparing…";
|
||||
final progress = stage?.progress ?? const Progress.indefinite();
|
||||
|
||||
final indicator = progress.when(
|
||||
definite: (definite) => _DefiniteIndicator(progress: definite),
|
||||
indefinite: () => SpinKitCubeGrid(color: Colors.white),
|
||||
indefinite: () => const _IndefiniteIndicator(),
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
state.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: _FrostCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(22),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Text(
|
||||
title,
|
||||
key: ValueKey(title),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
"Please don’t close the app",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(height: 180, child: indicator),
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(flex: 1, child: progressIndicator),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrapper UI + lifecycle glue.
|
||||
///
|
||||
/// - Runs [StagesWorker.start] exactly once (using hooks).
|
||||
/// - Uses `ref.listen` to complete [onCompleted] without rebuilding the logo.
|
||||
/// - Watches only [CurrentStage] for UI updates.
|
||||
class Bootstrapper extends HookConsumerWidget {
|
||||
final Stages stages;
|
||||
final Completer onCompleted;
|
||||
@@ -183,26 +319,71 @@ class Bootstrapper extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Future(() async => ref.read(stagesWorkerProvider.notifier).start(stages));
|
||||
final work = ref.watch(stagesWorkerProvider);
|
||||
useEffect(() {
|
||||
Future.microtask(
|
||||
() => ref.read(stagesWorkerProvider.notifier).start(stages),
|
||||
);
|
||||
|
||||
if (!onCompleted.isCompleted) {
|
||||
if (work.hasError) onCompleted.completeError(work.error!);
|
||||
if (work.hasValue && work.value!) onCompleted.complete();
|
||||
}
|
||||
return null;
|
||||
}, const []);
|
||||
ref.listen<AsyncValue<bool>>(stagesWorkerProvider, (_, next) {
|
||||
if (onCompleted.isCompleted) return;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
next.whenOrNull(
|
||||
data: (ok) {
|
||||
if (ok) onCompleted.complete();
|
||||
},
|
||||
error: (e, st) => onCompleted.completeError(e, st),
|
||||
);
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Center(child: Loader.playing(flavour: LoaderFlavour.big)),
|
||||
const _BackgroundGlow(),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||
child: Column(
|
||||
children: const [
|
||||
// Keep this as a const child so it doesn't restart animations
|
||||
// when the footer rebuilds.
|
||||
_LogoHeader(),
|
||||
SizedBox(height: 24),
|
||||
Expanded(child: Center(child: _BootstrapFooter())),
|
||||
SizedBox(height: 16),
|
||||
_BottomCaption(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(flex: 1, child: Container()),
|
||||
Flexible(flex: 1, child: _BootstrapFooter()),
|
||||
Flexible(flex: 1, child: Container()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogoHeader extends StatelessWidget {
|
||||
const _LogoHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 160,
|
||||
child: Center(child: Loader.playing(flavour: LoaderFlavour.big)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomCaption extends StatelessWidget {
|
||||
const _BottomCaption();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
"Initializing secure components…",
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,4 +269,190 @@ String toString() {
|
||||
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$StageState {
|
||||
|
||||
String get title; set title(String value); Progress get progress; set progress(Progress value);
|
||||
/// Create a copy of StageState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$StageStateCopyWith<StageState> get copyWith => _$StageStateCopyWithImpl<StageState>(this as StageState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is StageState&&(identical(other.title, title) || other.title == title)&&(identical(other.progress, progress) || other.progress == progress));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,title,progress);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'StageState(title: $title, progress: $progress)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $StageStateCopyWith<$Res> {
|
||||
factory $StageStateCopyWith(StageState value, $Res Function(StageState) _then) = _$StageStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String title, Progress progress
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$StageStateCopyWithImpl<$Res>
|
||||
implements $StageStateCopyWith<$Res> {
|
||||
_$StageStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final StageState _self;
|
||||
final $Res Function(StageState) _then;
|
||||
|
||||
/// Create a copy of StageState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? progress = null,}) {
|
||||
return _then(StageState(
|
||||
title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String,progress: null == progress ? _self.progress : progress // ignore: cast_nullable_to_non_nullable
|
||||
as Progress,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [StageState].
|
||||
extension StageStatePatterns on StageState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>() {final _that = this;
|
||||
switch (_that) {
|
||||
case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>() {final _that = this;
|
||||
switch (_that) {
|
||||
case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
|
||||
@@ -41,7 +41,7 @@ final class CurrentStageProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$currentStageHash() => r'9f294430d9b3fb21fd51f97163443b56bd152855';
|
||||
String _$currentStageHash() => r'7da6320c5c830e9438bf752dc990297dd410d25a';
|
||||
|
||||
abstract class _$CurrentStage extends $Notifier<StageState?> {
|
||||
StageState? build();
|
||||
@@ -86,7 +86,7 @@ final class StagesWorkerProvider
|
||||
StagesWorker create() => StagesWorker();
|
||||
}
|
||||
|
||||
String _$stagesWorkerHash() => r'68f4d51d98fa27e17496e24e4b8eeeca1815f323';
|
||||
String _$stagesWorkerHash() => r'44d8ae0ea8910ee05343c96e4056226e293afd67';
|
||||
|
||||
abstract class _$StagesWorker extends $AsyncNotifier<bool> {
|
||||
FutureOr<bool> build();
|
||||
|
||||
Reference in New Issue
Block a user