diff --git a/assets/blank-profile-picture-973460_1280.png b/assets/blank-profile-picture-973460_1280.png new file mode 100644 index 00000000..ad28f2ed Binary files /dev/null and b/assets/blank-profile-picture-973460_1280.png differ diff --git a/assets/svg/app_icon.svg b/assets/svg/app_icon.svg new file mode 100644 index 00000000..9fe6729c --- /dev/null +++ b/assets/svg/app_icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/app_router.dart b/lib/app_router.dart index b3dab4c1..e417d87f 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -2,6 +2,7 @@ import 'package:lb_planner/features/themes/themes.dart'; import 'package:lb_planner/shared/shared.dart'; import 'package:auto_route/auto_route.dart'; import 'package:lb_planner/features/auth/auth.dart'; +import 'package:lb_planner/features/update/update.dart'; part 'app_router.gr.dart'; @@ -111,13 +112,19 @@ class AppRouter extends _$AppRouter { DefaultRoute( page: LoginRoute.page, path: '/login', - ) + initial: true, + ), + DefaultRoute( + page: UpdateRoute.page, + path: '/update', + title: (context, data) => context.t.update_btn), ]; } /// Implements [CustomRoute] with some default settings. class DefaultRoute extends CustomRoute { /// Implements [CustomRoute] with some default settings. - DefaultRoute({required super.page, required super.path, super.initial}) + DefaultRoute( + {required super.page, required super.path, super.initial, super.title}) : super(transitionsBuilder: TransitionsBuilders.noTransition); } diff --git a/lib/app_router.gr.dart b/lib/app_router.gr.dart index 39def6dc..d133a416 100644 --- a/lib/app_router.gr.dart +++ b/lib/app_router.gr.dart @@ -27,6 +27,12 @@ abstract class _$AppRouter extends RootStackRouter { child: WrappedRoute(child: const ThemeDevelopmentScreen()), ); }, + UpdateRoute.name: (routeData) { + return AutoRoutePage( + routeData: routeData, + child: const UpdateScreen(), + ); + }, }; } @@ -57,3 +63,17 @@ class ThemeDevelopmentRoute extends PageRouteInfo { static const PageInfo page = PageInfo(name); } + +/// generated route for +/// [UpdateScreen] +class UpdateRoute extends PageRouteInfo { + const UpdateRoute({List? children}) + : super( + UpdateRoute.name, + initialChildren: children, + ); + + static const String name = 'UpdateRoute'; + + static const PageInfo page = PageInfo(name); +} diff --git a/lib/features/update/domain/models/patching_progress.dart b/lib/features/update/domain/models/patching_progress.dart index 48887503..05171e6d 100644 --- a/lib/features/update/domain/models/patching_progress.dart +++ b/lib/features/update/domain/models/patching_progress.dart @@ -1,5 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:lb_planner/shared/shared.dart'; +import 'package:lb_planner/features/update/update.dart'; part 'patching_progress.freezed.dart'; @@ -8,10 +8,16 @@ part 'patching_progress.freezed.dart'; class PatchingProgress with _$PatchingProgress { /// Represents the progress of patching the current app. factory PatchingProgress({ - /// The version that is being patched. - required Version version, + /// The release that is currently being patched. + required Release release, /// The progress of the patching process. required double progress, + + /// The error that occurred during patching, if any. + Object? error, + + /// The stack trace of the error that occurred during patching, if any. + StackTrace? stackTrace, }) = _PatchingProgress; } diff --git a/lib/features/update/domain/models/patching_progress.freezed.dart b/lib/features/update/domain/models/patching_progress.freezed.dart index 7f4f77fa..7a320940 100644 --- a/lib/features/update/domain/models/patching_progress.freezed.dart +++ b/lib/features/update/domain/models/patching_progress.freezed.dart @@ -16,12 +16,18 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$PatchingProgress { - /// The version that is being patched. - Version get version => throw _privateConstructorUsedError; + /// The release that is currently being patched. + Release get release => throw _privateConstructorUsedError; /// The progress of the patching process. double get progress => throw _privateConstructorUsedError; + /// The error that occurred during patching, if any. + Object? get error => throw _privateConstructorUsedError; + + /// The stack trace of the error that occurred during patching, if any. + StackTrace? get stackTrace => throw _privateConstructorUsedError; + @JsonKey(ignore: true) $PatchingProgressCopyWith get copyWith => throw _privateConstructorUsedError; @@ -33,9 +39,13 @@ abstract class $PatchingProgressCopyWith<$Res> { PatchingProgress value, $Res Function(PatchingProgress) then) = _$PatchingProgressCopyWithImpl<$Res, PatchingProgress>; @useResult - $Res call({Version version, double progress}); + $Res call( + {Release release, + double progress, + Object? error, + StackTrace? stackTrace}); - $VersionCopyWith<$Res> get version; + $ReleaseCopyWith<$Res> get release; } /// @nodoc @@ -51,26 +61,33 @@ class _$PatchingProgressCopyWithImpl<$Res, $Val extends PatchingProgress> @pragma('vm:prefer-inline') @override $Res call({ - Object? version = null, + Object? release = null, Object? progress = null, + Object? error = freezed, + Object? stackTrace = freezed, }) { return _then(_value.copyWith( - version: null == version - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as Version, + release: null == release + ? _value.release + : release // ignore: cast_nullable_to_non_nullable + as Release, progress: null == progress ? _value.progress : progress // ignore: cast_nullable_to_non_nullable as double, + error: freezed == error ? _value.error : error, + stackTrace: freezed == stackTrace + ? _value.stackTrace + : stackTrace // ignore: cast_nullable_to_non_nullable + as StackTrace?, ) as $Val); } @override @pragma('vm:prefer-inline') - $VersionCopyWith<$Res> get version { - return $VersionCopyWith<$Res>(_value.version, (value) { - return _then(_value.copyWith(version: value) as $Val); + $ReleaseCopyWith<$Res> get release { + return $ReleaseCopyWith<$Res>(_value.release, (value) { + return _then(_value.copyWith(release: value) as $Val); }); } } @@ -83,10 +100,14 @@ abstract class _$$_PatchingProgressCopyWith<$Res> __$$_PatchingProgressCopyWithImpl<$Res>; @override @useResult - $Res call({Version version, double progress}); + $Res call( + {Release release, + double progress, + Object? error, + StackTrace? stackTrace}); @override - $VersionCopyWith<$Res> get version; + $ReleaseCopyWith<$Res> get release; } /// @nodoc @@ -100,18 +121,25 @@ class __$$_PatchingProgressCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ - Object? version = null, + Object? release = null, Object? progress = null, + Object? error = freezed, + Object? stackTrace = freezed, }) { return _then(_$_PatchingProgress( - version: null == version - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as Version, + release: null == release + ? _value.release + : release // ignore: cast_nullable_to_non_nullable + as Release, progress: null == progress ? _value.progress : progress // ignore: cast_nullable_to_non_nullable as double, + error: freezed == error ? _value.error : error, + stackTrace: freezed == stackTrace + ? _value.stackTrace + : stackTrace // ignore: cast_nullable_to_non_nullable + as StackTrace?, )); } } @@ -119,19 +147,31 @@ class __$$_PatchingProgressCopyWithImpl<$Res> /// @nodoc class _$_PatchingProgress implements _PatchingProgress { - _$_PatchingProgress({required this.version, required this.progress}); + _$_PatchingProgress( + {required this.release, + required this.progress, + this.error, + this.stackTrace}); - /// The version that is being patched. + /// The release that is currently being patched. @override - final Version version; + final Release release; /// The progress of the patching process. @override final double progress; + /// The error that occurred during patching, if any. + @override + final Object? error; + + /// The stack trace of the error that occurred during patching, if any. + @override + final StackTrace? stackTrace; + @override String toString() { - return 'PatchingProgress(version: $version, progress: $progress)'; + return 'PatchingProgress(release: $release, progress: $progress, error: $error, stackTrace: $stackTrace)'; } @override @@ -139,13 +179,17 @@ class _$_PatchingProgress implements _PatchingProgress { return identical(this, other) || (other.runtimeType == runtimeType && other is _$_PatchingProgress && - (identical(other.version, version) || other.version == version) && + (identical(other.release, release) || other.release == release) && (identical(other.progress, progress) || - other.progress == progress)); + other.progress == progress) && + const DeepCollectionEquality().equals(other.error, error) && + (identical(other.stackTrace, stackTrace) || + other.stackTrace == stackTrace)); } @override - int get hashCode => Object.hash(runtimeType, version, progress); + int get hashCode => Object.hash(runtimeType, release, progress, + const DeepCollectionEquality().hash(error), stackTrace); @JsonKey(ignore: true) @override @@ -156,18 +200,28 @@ class _$_PatchingProgress implements _PatchingProgress { abstract class _PatchingProgress implements PatchingProgress { factory _PatchingProgress( - {required final Version version, - required final double progress}) = _$_PatchingProgress; + {required final Release release, + required final double progress, + final Object? error, + final StackTrace? stackTrace}) = _$_PatchingProgress; @override - /// The version that is being patched. - Version get version; + /// The release that is currently being patched. + Release get release; @override /// The progress of the patching process. double get progress; @override + + /// The error that occurred during patching, if any. + Object? get error; + @override + + /// The stack trace of the error that occurred during patching, if any. + StackTrace? get stackTrace; + @override @JsonKey(ignore: true) _$$_PatchingProgressCopyWith<_$_PatchingProgress> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/features/update/domain/providers/is_update_available_provider_state.dart b/lib/features/update/domain/providers/is_update_available_provider_state.dart index 3575bfd2..4366ae8d 100644 --- a/lib/features/update/domain/providers/is_update_available_provider_state.dart +++ b/lib/features/update/domain/providers/is_update_available_provider_state.dart @@ -10,17 +10,35 @@ class IsUpdateAvailableProviderState extends AsyncNotifier { /// The [ReleaseRepository] instance to use. late final ReleaseRepository releaseRepository; + late Release _latestRelease; + + /// The latest release published. + /// + /// Updates when [checkForUpdates] is called. + Release get latestRelease => _latestRelease; + @override - FutureOr build() { + FutureOr build() async { releaseRepository = ref.watch(releaseRepositoryProvider); - return releaseRepository.isUpdateAvailable(); + return _checkForUpdates(); } - /// Checks whether an update is available. + /// Checks if a new release has been published. Future checkForUpdates() async { state = AsyncLoading(); - state = await AsyncValue.guard(releaseRepository.isUpdateAvailable); + state = await AsyncValue.guard(_checkForUpdates); + } + + /// INTERNAL USE ONLY! + /// + /// Checks whether an update is available. + /// + /// Called by [build] and [checkForUpdates]. + Future _checkForUpdates() async { + _latestRelease = await releaseRepository.getLatestRelease(); + + return releaseRepository.isUpdateAvailable(); } } diff --git a/lib/features/update/domain/providers/patching_progress_provider.dart b/lib/features/update/domain/providers/patching_progress_provider.dart index 2ac0e006..8e4dc592 100644 --- a/lib/features/update/domain/providers/patching_progress_provider.dart +++ b/lib/features/update/domain/providers/patching_progress_provider.dart @@ -1,25 +1,23 @@ import 'package:lb_planner/features/update/update.dart'; import 'package:riverpod/riverpod.dart'; -/// Provides the current [PatchingProgress]. +/// Provides the progress of the current patching process. +/// +/// The progess is represented as double between `0.0` and `1.0`. /// /// NOTE: Resolves to `null` if no patching is in progress. /// -/// To start patching, use [patchingProgressController]. +/// If you want start a patching process, use [patchingProgressController]. final patchingProgressProvider = - StateNotifierProvider( - (ref) { - final patcherService = ref.watch(patcherServiceProvider); - final releaseRepository = ref.watch(releaseRepositoryProvider); - - return PatchingProgressProviderState( - patcherService, - releaseRepository, - ); - }, + AsyncNotifierProvider( + () => PatchingProgressProviderState(), ); -/// Expososes the controller for [patchingProgressProvider]. +/// Exposes methods for patching the app. +/// +/// If [patchingProgressProvider] resolves to [AsyncLoading], the patcher is currently being initialized and it unsafe to use any properties or methods. +/// +/// If [patchingProgressProvider] resolves to [AsyncError], there was an error while patching or an error during setup, which also makes it unsafe to call any methods or use any properties. /// -/// To receive updates on the patching progress, use [patchingProgressProvider]. +/// If you want to read the current patching progress, use [patchingProgressProvider]. final patchingProgressController = patchingProgressProvider.notifier; diff --git a/lib/features/update/domain/providers/patching_progress_provider_state.dart b/lib/features/update/domain/providers/patching_progress_provider_state.dart index ba5fe332..8a3b8df3 100644 --- a/lib/features/update/domain/providers/patching_progress_provider_state.dart +++ b/lib/features/update/domain/providers/patching_progress_provider_state.dart @@ -1,34 +1,81 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; import 'package:lb_planner/features/update/update.dart'; import 'package:riverpod/riverpod.dart'; -/// Provides the current [PatchingProgress]. +/// Provides the progress of the current patching process. +/// +/// The progess is represented as double between `0.0` and `1.0`. /// /// NOTE: Resolves to `null` if no patching is in progress. -class PatchingProgressProviderState extends StateNotifier { - /// The [PatcherService] instance to use for patching. - final PatcherService patcherService; +/// +/// If this resolves to [AsyncLoading] the patcher is currently being initialized and it unsafe to use any properties or methods. +/// +/// If this resolves to [AsyncError], there was an error while patching or an error during setup, which also makes it unsafe to call any methods or use any properties. +class PatchingProgressProviderState extends AsyncNotifier { + late PatcherService _patcherService; + + late ReleaseRepository _releaseRepository; - /// The [ReleaseRepository] instance to use for fetching releases. - final ReleaseRepository releaseRepository; + late Release _target; + + /// The target release that should be installed. + Release get target => _target; /// Provides the current [PatchingProgress]. /// /// NOTE: Resolves to `null` if no patching is in progress. - PatchingProgressProviderState(this.patcherService, this.releaseRepository) - : super(null); + PatchingProgressProviderState(); + + @override + FutureOr build() async { + _patcherService = ref.read(patcherServiceProvider); + + _releaseRepository = ref.read(releaseRepositoryProvider); + + // watch `isUpdateAvailableProvider` so we refetch the lateste release when [IsUpdateAvailableProviderState.checkForUpdates] is called + // + // this way we ensure that we always have the latest release when patching + ref.watch(isUpdateAvailableProvider); + + _target = await _releaseRepository.getLatestRelease(); - /// Starts patching the current app. + return null; + } + + /// Downloads and installs the latest version of the app. + /// + /// NOTE: [state] will resolve to an [AsyncError] if [canPatch] returns `false`. Future patch() async { - var latest = await releaseRepository.getLatestRelease(); - - await patcherService.patch( - latest, - onProgress: (progress) { - state = PatchingProgress( - version: latest.version, - progress: progress, + state = await AsyncValue.guard(() async { + if (!canPatch) { + throw UnsupportedError( + '${_patcherService.runtimeType} does not support patching.', ); - }, - ); + } + + await _patcherService.patch( + target, + onProgress: (progress) { + state = AsyncValue.data(progress); + }, + ); + + return null; + }); } + + /// Whether the app can be patched automatically. + /// + /// If this returns `false`, the user will have to manually install the update. + /// + /// In this case [patch] will throw an [UnsupportedError]. Use [getInstructions] to receive instructions for manually installing the update. + bool get canPatch => _patcherService.canPatch; + + /// Returns the instructions for manually installing a given release in markdown format. + /// + /// NOTE: [state] will resolve to an [AsyncError] if [canPatch] returns `true`. + String getInstructions(BuildContext context) => + _patcherService.getInstructions(context, target); } diff --git a/lib/features/update/presentation/presentation.dart b/lib/features/update/presentation/presentation.dart new file mode 100644 index 00000000..80f5a18c --- /dev/null +++ b/lib/features/update/presentation/presentation.dart @@ -0,0 +1 @@ +export 'screens/screens.dart'; diff --git a/lib/features/update/presentation/screens/screens.dart b/lib/features/update/presentation/screens/screens.dart new file mode 100644 index 00000000..c4e80770 --- /dev/null +++ b/lib/features/update/presentation/screens/screens.dart @@ -0,0 +1 @@ +export 'update_screen.dart'; diff --git a/lib/features/update/presentation/screens/update_screen.dart b/lib/features/update/presentation/screens/update_screen.dart new file mode 100644 index 00000000..b40bbf19 --- /dev/null +++ b/lib/features/update/presentation/screens/update_screen.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lb_planner/shared/shared.dart'; +import 'package:lb_planner/features/update/update.dart'; + +/// Renders an update screen, which allows the user to update the app. +@RoutePage() +class UpdateScreen extends ConsumerStatefulWidget with SidebarWrapperMixin { + /// Renders an update screen, which allows the user to update the app. + const UpdateScreen({Key? key}) : super(key: key); + + /// Factor multiplied by the screen width to get the padding. + static const paddingFactor = 0.1; + + /// Size of the app icon. + static const double iconSize = 60; + + @override + ConsumerState createState() => _UpdateScreenState(); +} + +class _UpdateScreenState extends ConsumerState { + void showInstructions() { + WidgetsBinding.instance.addPostFrameCallback((_) { + showAlertDialog( + context, + title: t.update_dialog_title, + message: t.update_dialog_noAutoUpdate, + body: SizedBox( + height: 150, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.read(patchingProgressController).canPatch + ? t.update_dialog_helpNeeded + : t.update_dialog_noAutoUpdate, + overflow: TextOverflow.visible, + ), + Spacing.large(), + Expanded( + child: MarkdownView( + ref.read(patchingProgressController).getInstructions(context), + ), + ), + ], + ), + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + final progressController = ref.watch(patchingProgressController); + final patchingProgress = ref.watch(patchingProgressProvider); + + if (patchingProgress.isLoading) { + return Container( + color: context.theme.colorScheme.background, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + return Material( + child: Container( + color: context.theme.colorScheme.background, + padding: EdgeInsets.symmetric( + horizontal: + MediaQuery.of(context).size.width * UpdateScreen.paddingFactor, + vertical: + MediaQuery.of(context).size.height * UpdateScreen.paddingFactor, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + VectorImage('assets/svg/app_icon.svg', + height: UpdateScreen.iconSize), + Spacing.small(), + if (patchingProgress.hasValue) + Text( + t.update_patchNotes(progressController.target.name), + style: TextStyle( + fontSize: 25, fontWeight: FontWeight.bold), + ), + ], + ), + if (patchingProgress.hasError) + ElevatedButton( + onPressed: progressController.patch, + child: Text(t.update_btnErr), + ), + if (patchingProgress.hasValue && patchingProgress.value != null) + Row( + children: [ + Text( + t.update_downloading( + patchingProgress.value!.round() * 100), + style: TextStyle(fontWeight: FontWeight.normal), + ), + Spacing.small(), + CircularProgressIndicator(), + ], + ), + if (patchingProgress.hasValue && patchingProgress.value != null) + Text( + t.update_installing, + style: TextStyle(fontWeight: FontWeight.normal), + ), + if (!progressController.canPatch) + ElevatedButton( + onPressed: showInstructions, + child: Text(t.update_btnInstall)), + ], + ), + Spacing.large(), + if (patchingProgress.hasValue) + Expanded( + child: MarkdownView(progressController.target.changelog), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/update/update.dart b/lib/features/update/update.dart index 9ac5c00a..a0743a91 100644 --- a/lib/features/update/update.dart +++ b/lib/features/update/update.dart @@ -1 +1,2 @@ export "domain/domain.dart"; +export 'presentation/presentation.dart'; diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 9224d19d..c93839b0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -55,11 +55,7 @@ }, "@update_downloading": { "description": "Update downloading text", - "placeholders": { - "filename": { - "example": "Setup.exe", - "type": "String" - }, + "placeholders":{ "progress": { "example": "50", "type": "int" @@ -267,7 +263,7 @@ "update_btn": "Update", "update_btnErr": "Retry", "update_btnInstall": "Install", - "update_downloading": "Downloading {filename} ({progress}%)", + "update_downloading": "Downloading {progress}%", "update_dialog_noAutoUpdate": "LB Planner cannot be updated automatically on this platform. Please run the command below to update.", "update_dialog_helpNeeded": "LB Planner needs manual intervention to update. Please run the command below to complete the update.", "update_dialog_title": "Manual steps required!", diff --git a/lib/shared/presentation/widgets/conditional_widget.dart b/lib/shared/presentation/widgets/conditional_widget.dart new file mode 100644 index 00000000..453872a8 --- /dev/null +++ b/lib/shared/presentation/widgets/conditional_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +/// Use this to conditionally swap between widgets. +class ConditionalWidget extends StatelessWidget { + /// Constructs a new [ConditionalWidget] with the given [condition], [ifTrue] and [ifFalse]. + /// If [condition] is true, [ifTrue] will be used. + /// If [condition] is false, [ifFalse] will be used. + const ConditionalWidget( + {Key? key, + required this.condition, + required this.ifTrue, + required this.ifFalse}) + : super(key: key); + + /// The condition to check. + final bool condition; + + /// The widget to show if the condition is true. + final Widget ifTrue; + + /// The widget to show if the condition is false. + final Widget ifFalse; + + @override + Widget build(BuildContext context) => condition ? ifTrue : ifFalse; +} diff --git a/lib/shared/presentation/widgets/dialog.dart b/lib/shared/presentation/widgets/dialog.dart new file mode 100644 index 00000000..980c075b --- /dev/null +++ b/lib/shared/presentation/widgets/dialog.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lb_planner/shared/shared.dart'; + +/// Shows custom dialogs +class Dialog extends StatefulWidget { + /// Creates the structure of the confirm dialog + Dialog.confirm({ + Key? key, + required this.header, + required this.body, + this.confirmText, + this.cancelText, + required this.onConfirm, + this.onCancel, + required this.removeFromWidgetTree, + this.confirmIsBad = true, + required this.scrollable, + }) : super(key: key) { + confirmOnly = false; + } + + /// Creates the structure of the alert dialog + Dialog.alert({ + Key? key, + required this.header, + required this.body, + this.onConfirm, + this.confirmText, + required this.removeFromWidgetTree, + required this.scrollable, + }) : super(key: key) { + confirmIsBad = false; + confirmOnly = true; + } + + /// The header of the dialog. + final Widget? header; + + /// The body of the dialog. + final Widget body; + + /// Whether the confirm button should have errorColor as it's background color. + late final bool confirmIsBad; + + /// The text of the confirm button. + final String? confirmText; + + /// The text of the cancel button. + late final String? cancelText; + + /// The boolean that determines if the dialog has just one button. + late final bool confirmOnly; + + /// Whether the dialog body should be scrollable. + final bool scrollable; + + /// The callback that is called when the user confirms the dialog. + final Function()? onConfirm; + + /// Called when the dialog has to be removed from the widget tree. + final VoidCallback removeFromWidgetTree; + + /// The callback that is called when the user cancels the dialog. + late final Function()? onCancel; + + /// The padding between the elements in the dialog. + static const double padding = 20; + + /// The width factor of the body in the dialog. + static const double widthFactor = .5; + + /// The height factor of the body in the dialog. + static const double heightFactor = .8; + + /// The font size of the buttons in the dialog. + static const double btnFontSize = 16; + + /// The padding of the buttons in the dialog. + static const double btnPadding = 14; + + /// The font size of the title. + static const double titleFontSize = 30; + + @override + State createState() => _DialogState(); +} + +class _DialogState extends State with TickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + _controller = + AnimationController(vsync: this, duration: Duration(milliseconds: 300)); + _controller.forward(); + super.initState(); + focusNode.addListener(_ensureFocus); + + focusNode.requestFocus(); + } + + /// Removes the widget from the widget tree. + Future close() async { + await _controller.reverse(); + widget.removeFromWidgetTree(); + } + + ///Ensures focus if the focusNode doesn't have focus. + void _ensureFocus() async { + if (focusNode.hasFocus) return; + + await Future.delayed(Duration(milliseconds: 300)); + + if (!mounted) return; + + focusNode.requestFocus(); + } + + final focusNode = FocusNode(skipTraversal: true); + + @override + Widget build(BuildContext context) { + return RawKeyboardListener( + autofocus: true, + focusNode: focusNode, + onKey: (event) { + if (event.logicalKey == LogicalKeyboardKey.escape && + focusNode.hasPrimaryFocus) { + close(); + } + }, + child: FadeTransition( + opacity: Tween(begin: 0.4, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 0.5), + ), + ), + child: ScaleTransition( + child: AlertDialog( + title: widget.header, + titlePadding: EdgeInsets.all(Dialog.padding), + buttonPadding: + EdgeInsets.only(left: Dialog.padding, right: Dialog.padding), + contentPadding: EdgeInsets.only( + bottom: Dialog.padding, + left: Dialog.padding, + right: Dialog.padding), + content: ConstrainedBox( + constraints: BoxConstraints( + minWidth: + MediaQuery.of(context).size.width * Dialog.widthFactor, + maxHeight: + MediaQuery.of(context).size.height * Dialog.heightFactor, + maxWidth: + MediaQuery.of(context).size.width * Dialog.widthFactor, + ), + child: AnimatedSize( + duration: Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + child: ConditionalWrapper( + condition: widget.scrollable, + wrapper: (context, child) => SingleChildScrollView( + controller: ScrollController(), + child: GestureDetector( + onTap: () => + FocusManager.instance.primaryFocus?.unfocus(), + child: child, + ), + ), + child: widget.body, + ), + ), + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (!widget.confirmOnly) + ElevatedButton( + child: Text(widget.cancelText ?? t.dialog_cancel), + style: ElevatedButton.styleFrom( + backgroundColor: widget.confirmIsBad + ? context.theme.colorScheme.primary + : context.theme.colorScheme.error, + textStyle: TextStyle(fontSize: Dialog.btnFontSize), + padding: EdgeInsets.all(Dialog.btnPadding), + ), + onPressed: () async { + await close(); + widget.onCancel?.call(); + }, + ), + Spacing.medium(), + ElevatedButton( + child: Text(widget.confirmText ?? + (widget.confirmOnly + ? t.alertDialog_confirm + : t.dialog_confirm)), + style: ElevatedButton.styleFrom( + backgroundColor: widget.confirmIsBad + ? context.theme.colorScheme.error + : context.theme.colorScheme.primary, + textStyle: TextStyle(fontSize: Dialog.btnFontSize), + padding: EdgeInsets.all(Dialog.btnPadding), + ), + onPressed: () async { + await close(); + widget.onConfirm?.call(); + }, + ), + ], + ) + ], + backgroundColor: context.theme.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + // ignore: no-magic-number + scale: Tween(begin: 1, end: 0.85).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 0.5, curve: Curves.easeOut), + ), + ), + ), + ), + ); + } +} + +/// Shows an confirm dialog +void showConfirmDialog( + BuildContext context, { + String? title, + Widget? header, + Widget? body, + String? confirmText, + String? cancelText, + Function()? onConfirm, + Function()? onCancel, + String? message, + bool confirmIsBad = true, + bool scrollable = true, +}) { + assert(body != null || message != null, + 'Either body or message must be provided.'); + assert(header != null || title != null, + 'Either header or title must be provided.'); + + var key = GlobalKey<_DialogState>(); + + OverlayEntry? dialogOverLay; + OverlayEntry background = + _generateBackground(() => key.currentState!.close()); + + var dialog = Dialog.confirm( + key: key, + header: header ?? + Text( + title!, + style: TextStyle(fontSize: Dialog.titleFontSize), + ), + body: ConditionalWidget( + condition: body != null, + ifTrue: body!, + ifFalse: Text(message!, + style: TextStyle( + overflow: TextOverflow.visible, + fontSize: 12, + letterSpacing: 0.4)), + ), + confirmText: confirmText, + cancelText: cancelText, + onConfirm: onConfirm, + onCancel: onCancel, + confirmIsBad: confirmIsBad, + scrollable: scrollable, + removeFromWidgetTree: () { + dialogOverLay!.remove(); + background.remove(); + }, + ); + + dialogOverLay = OverlayEntry( + builder: (context) => dialog, + ); + + Overlay.of(context).insert(background); + Overlay.of(context).insert(dialogOverLay); +} + +/// Shows an alert dialog +void showAlertDialog( + BuildContext context, { + String? title, + Widget? header, + Widget? body, + String? message, + String? confirmText, + Function()? onConfirm, + bool scrollable = true, +}) { + assert(body != null || message != null, + 'Either body or message must be provided.'); + assert(header != null || title != null, + 'Either header or title must be provided.'); + var key = GlobalKey<_DialogState>(); + + OverlayEntry? dialogOverLay; + OverlayEntry background = + _generateBackground(() => key.currentState!.close()); + + var dialog = Dialog.alert( + key: key, + header: header ?? + Text( + title!, + style: TextStyle(fontSize: Dialog.titleFontSize), + ), + body: ConditionalWidget( + condition: body != null, + ifTrue: body!, + ifFalse: Text(message!, + style: TextStyle( + overflow: TextOverflow.visible, + fontSize: 12, + letterSpacing: 0.4)), + ), + onConfirm: onConfirm, + confirmText: confirmText, + scrollable: scrollable, + removeFromWidgetTree: () { + dialogOverLay!.remove(); + background.remove(); + }, + ); + + dialogOverLay = OverlayEntry( + builder: (context) => dialog, + ); + + Overlay.of(context).insert(background); + Overlay.of(context).insert(dialogOverLay); +} + +/// generates a background overlay for dismissing the dialog +OverlayEntry _generateBackground(Function() dismiss) { + return OverlayEntry( + builder: (context) => GestureDetector( + onTap: dismiss, + child: Container( + color: Colors.black38, + ), + ), + ); +} diff --git a/lib/shared/presentation/widgets/markdown.dart b/lib/shared/presentation/widgets/markdown.dart new file mode 100644 index 00000000..f91c0046 --- /dev/null +++ b/lib/shared/presentation/widgets/markdown.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_vector_icons/flutter_vector_icons.dart'; +import 'package:lb_planner/shared/shared.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Renders a [MarkdownView] +/// +/// Also allows to loading content from the internet. +class MarkdownView extends ConsumerWidget { + /// Renders a Markdown wit the [data] + const MarkdownView(this.data, {Key? key}) + : source = null, + assert(data != null), + super(key: key); + + /// Renders a [MarkdownView] + /// + /// Also allows to loading content from the internet. + const MarkdownView.network(this.source, {Key? key}) + : data = null, + assert(source != null), + super(key: key); + + /// The markdown content to display. + final String? data; + + /// The url to load the markdown from. + final Uri? source; + + @override + Widget build(context, ref) { + final networkService = ref.watch(networkServiceProvider); + + return ConditionalWidget( + condition: data == null, + ifTrue: Builder(builder: (context) { + return FutureBuilder( + future: networkService.get(source.toString()), + builder: (context, snapshot) { + if (snapshot.hasData) { + if (snapshot.data!.isOk) { + return _buildMarkdown(context, snapshot.data!.body); + } + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + FontAwesome.exclamation_triangle, + color: context.theme.colorScheme.error, + size: 60, + ), + Spacing.small(), + Text( + context.t.widgets_markdown_networkError(source.toString()), + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, letterSpacing: 0.4), + ), + Spacing.large(), + ElevatedButton( + child: Text( + context.t.widgets_markdown_networkError_openInBrowser), + onPressed: () => launchUrl(source!), + ) + ], + ); + } else { + return Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + }), + ifFalse: _buildMarkdown(context, data!), + ); + } + + /// Builds the Markdown View with the specified [data] and style. + Widget _buildMarkdown(BuildContext context, String data) { + return Markdown( + data: data, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context), + ).copyWith( + blockquoteDecoration: BoxDecoration( + color: context.theme.colorScheme.primary, + borderRadius: BorderRadius.circular(5), + ), + a: TextStyle(color: context.theme.colorScheme.primary), + h1: TextStyle(fontWeight: FontWeight.bold, fontSize: null), + h2: TextStyle(fontWeight: FontWeight.bold, fontSize: null), + h3: TextStyle(fontWeight: FontWeight.bold, fontSize: null), + h4: TextStyle(fontWeight: FontWeight.bold, fontSize: null), + h5: TextStyle(fontWeight: FontWeight.bold, fontSize: null), + h6: TextStyle(fontWeight: FontWeight.bold, fontSize: null), + code: TextStyle( + color: context.theme.textTheme.bodyLarge!.color!, + backgroundColor: context.theme.colorScheme.primary, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + onTapLink: (text, href, title) { + if (href == null) return; + + launchUrl(Uri.parse(href)); + }, + ); + } +} diff --git a/lib/shared/presentation/widgets/screen_title_bar.dart b/lib/shared/presentation/widgets/screen_title_bar.dart index a853510d..03701712 100644 --- a/lib/shared/presentation/widgets/screen_title_bar.dart +++ b/lib/shared/presentation/widgets/screen_title_bar.dart @@ -71,7 +71,8 @@ class ScreenTitleBar extends ConsumerWidget { width: profileImageSize, height: profileImageSize, child: CircleAvatar( - // TODO: backgroundImage: fallback image + backgroundImage: AssetImage( + "assets/blank-profile-picture-973460_1280.png"), foregroundImage: NetworkImage(user.profileImageUrl), ), ), diff --git a/lib/shared/presentation/widgets/snippet.dart b/lib/shared/presentation/widgets/snippet.dart new file mode 100644 index 00000000..3fab6d4b --- /dev/null +++ b/lib/shared/presentation/widgets/snippet.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lb_planner/shared/shared.dart'; + +/// Displays code snippets with copy functionality. +class Snippet extends StatelessWidget { + /// Displays code snippets with copy functionality. + const Snippet({Key? key, required this.code}) : super(key: key); + + /// The code to display. + final String code; + + @override + Widget build(context) { + var color = context.theme.colorScheme.primary.withOpacity(0.5); + + return Container( + decoration: BoxDecoration( + color: context.theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(5), + ), + padding: EdgeInsets.all(Spacing.smallSpacing), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + controller: ScrollController(), + scrollDirection: Axis.horizontal, + clipBehavior: Clip.hardEdge, + child: Text( + code, + style: TextStyle( + color: context.theme.colorScheme.primary, fontSize: 16), + ), + ), + ), + Spacing.xs(), + HoverBuilder( + builder: (context, hovering) => Icon( + Icons.copy, + color: hovering ? context.theme.colorScheme.primary : color, + ), + onTap: () => Clipboard.setData(ClipboardData(text: code)), + ), + ], + ), + ); + } +} diff --git a/lib/shared/presentation/widgets/widgets.dart b/lib/shared/presentation/widgets/widgets.dart index 6e24c528..55eab56b 100644 --- a/lib/shared/presentation/widgets/widgets.dart +++ b/lib/shared/presentation/widgets/widgets.dart @@ -10,3 +10,7 @@ export 'scale_on_hover.dart'; export 'offset_on_hover.dart'; export 'hover_builder.dart'; export 'hoverable_widget.dart'; +export 'conditional_widget.dart'; +export 'dialog.dart'; +export 'markdown.dart'; +export 'snippet.dart';