From 9c2423302756ee31b04342eb1bdc5fe7bf8876a8 Mon Sep 17 00:00:00 2001 From: KeidsID Date: Wed, 15 Jan 2025 17:54:09 +0800 Subject: [PATCH] refactor(lib-interfaces): refactor some widgets ds-12 --- lib/interfaces/libs/widgets.dart | 10 +- .../{ => list_tile}/app_about_list_tile.dart | 17 +- .../widgets/list_tile/locale_list_tile.dart | 47 ++++++ .../widgets/list_tile/theme_list_tile.dart | 47 ++++++ .../{ => media}/common_network_image.dart | 0 .../widgets/{ => media}/custom_camera.dart | 150 +++++++++--------- .../{ => media}/image_from_x_file.dart | 0 .../modules/routes/root/profile_route.dart | 66 +------- .../stories/routes/story_detail_route.dart | 72 +++++---- 9 files changed, 225 insertions(+), 184 deletions(-) rename lib/interfaces/libs/widgets/{ => list_tile}/app_about_list_tile.dart (87%) create mode 100644 lib/interfaces/libs/widgets/list_tile/locale_list_tile.dart create mode 100644 lib/interfaces/libs/widgets/list_tile/theme_list_tile.dart rename lib/interfaces/libs/widgets/{ => media}/common_network_image.dart (100%) rename lib/interfaces/libs/widgets/{ => media}/custom_camera.dart (79%) rename lib/interfaces/libs/widgets/{ => media}/image_from_x_file.dart (100%) diff --git a/lib/interfaces/libs/widgets.dart b/lib/interfaces/libs/widgets.dart index bad4e01..0f4dc78 100644 --- a/lib/interfaces/libs/widgets.dart +++ b/lib/interfaces/libs/widgets.dart @@ -1,8 +1,10 @@ -export "widgets/app_about_list_tile.dart"; -export "widgets/common_network_image.dart"; -export "widgets/custom_camera.dart"; export "widgets/email_text_field.dart"; -export "widgets/image_from_x_file.dart"; +export "widgets/list_tile/app_about_list_tile.dart"; +export "widgets/list_tile/locale_list_tile.dart"; +export "widgets/list_tile/theme_list_tile.dart"; +export "widgets/media/common_network_image.dart"; +export "widgets/media/custom_camera.dart"; +export "widgets/media/image_from_x_file.dart"; export "widgets/password_text_field.dart"; export "widgets/sized_error_widget.dart"; export "widgets/story_card.dart"; diff --git a/lib/interfaces/libs/widgets/app_about_list_tile.dart b/lib/interfaces/libs/widgets/list_tile/app_about_list_tile.dart similarity index 87% rename from lib/interfaces/libs/widgets/app_about_list_tile.dart rename to lib/interfaces/libs/widgets/list_tile/app_about_list_tile.dart index 7edbcad..0c9ae12 100644 --- a/lib/interfaces/libs/widgets/app_about_list_tile.dart +++ b/lib/interfaces/libs/widgets/list_tile/app_about_list_tile.dart @@ -11,7 +11,7 @@ import "package:dicoding_story_fl/libs/constants.dart"; class AppAboutListTile extends StatelessWidget { const AppAboutListTile({super.key}); - VoidCallback _onTapLink(BuildContext context, {required Uri url}) { + VoidCallback _handleUrlVisit(BuildContext context, {required Uri url}) { return () async { if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { if (kIsWeb) return; // skip invalid error on web @@ -21,8 +21,8 @@ class AppAboutListTile extends StatelessWidget { context: context, builder: (context) { return AlertDialog( - title: const Text("Hyperlink Fail"), - content: Text("Cannot launch $url"), + title: const Text("Url Visit Error"), + content: Text("Cannot visit $url"), actions: [ TextButton( onPressed: () => Navigator.maybePop(context), @@ -48,7 +48,8 @@ class AppAboutListTile extends StatelessWidget { image: AssetImages.appIconL, width: 80.0, ), - applicationVersion: "v${package.version}+${package.buildNumber}", + applicationVersion: + "v${package.version}${package.buildNumber.isNotEmpty ? "+${package.buildNumber}" : ""}", applicationLegalese: "MIT License\n\n" "Copyright (c) 2024 Kemal Idris [KeidsID]", aboutBoxChildren: [ @@ -65,7 +66,7 @@ class AppAboutListTile extends StatelessWidget { text: "App Icon", style: linkTextStyle, recognizer: TapGestureRecognizer() - ..onTap = _onTapLink( + ..onTap = _handleUrlVisit( context, url: Uri.parse( "https://www.flaticon.com/free-icon/content_15911316", @@ -77,7 +78,7 @@ class AppAboutListTile extends StatelessWidget { text: "Adrly", style: linkTextStyle, recognizer: TapGestureRecognizer() - ..onTap = _onTapLink( + ..onTap = _handleUrlVisit( context, url: Uri.parse( "https://www.flaticon.com/authors/adrly", @@ -89,7 +90,7 @@ class AppAboutListTile extends StatelessWidget { text: "flaticon.com", style: linkTextStyle, recognizer: TapGestureRecognizer() - ..onTap = _onTapLink( + ..onTap = _handleUrlVisit( context, url: Uri.parse("https://www.flaticon.com/"), ), @@ -103,7 +104,7 @@ class AppAboutListTile extends StatelessWidget { Wrap( children: [ TextButton( - onPressed: _onTapLink( + onPressed: _handleUrlVisit( context, url: Uri.parse("https://github.com/KeidsID/dicoding_story_fl"), ), diff --git a/lib/interfaces/libs/widgets/list_tile/locale_list_tile.dart b/lib/interfaces/libs/widgets/list_tile/locale_list_tile.dart new file mode 100644 index 0000000..065bab3 --- /dev/null +++ b/lib/interfaces/libs/widgets/list_tile/locale_list_tile.dart @@ -0,0 +1,47 @@ +import "package:flutter/material.dart"; +import "package:provider/provider.dart"; + +import "package:dicoding_story_fl/interfaces/libs/l10n.dart"; +import "package:dicoding_story_fl/interfaces/libs/providers.dart"; + +class LocaleListTile extends StatelessWidget { + const LocaleListTile({super.key}); + + @override + Widget build(BuildContext context) { + final appL10n = AppL10n.of(context)!; + + return ListTile( + leading: const Icon(Icons.language_outlined), + title: Text(appL10n.language), + trailing: Builder(builder: (context) { + final localeProvider = context.watch(); + + String localeString(String localeString) { + return switch (localeString) { + "en" => "English", + "id" => "Bahasa Indonesia", + _ => appL10n.flThemeMode(ThemeMode.system.name), + }; + } + + return DropdownButton( + value: localeProvider.value, + onChanged: (value) => localeProvider.value = value, + items: [ + DropdownMenuItem( + value: null, + child: Text(localeString("system")), + ), + ...AppL10n.supportedLocales.map((e) { + return DropdownMenuItem( + value: e, + child: Text(localeString("$e")), + ); + }) + ], + ); + }), + ); + } +} diff --git a/lib/interfaces/libs/widgets/list_tile/theme_list_tile.dart b/lib/interfaces/libs/widgets/list_tile/theme_list_tile.dart new file mode 100644 index 0000000..da07b05 --- /dev/null +++ b/lib/interfaces/libs/widgets/list_tile/theme_list_tile.dart @@ -0,0 +1,47 @@ +import "package:flutter/material.dart"; +import "package:provider/provider.dart"; + +import "package:dicoding_story_fl/interfaces/libs/l10n.dart"; +import "package:dicoding_story_fl/interfaces/libs/providers.dart"; + +class ThemeListTile extends StatelessWidget { + const ThemeListTile({super.key}); + + @override + Widget build(BuildContext context) { + final appL10n = AppL10n.of(context)!; + + return ListTile( + leading: const Icon(Icons.color_lens_outlined), + title: Text(appL10n.appTheme), + trailing: Builder(builder: (context) { + final themeModeProvider = context.watch(); + + final icons = ThemeMode.values.map((e) { + return switch (e) { + ThemeMode.system => Icons.settings_outlined, + ThemeMode.light => Icons.light_mode_outlined, + ThemeMode.dark => Icons.dark_mode_outlined, + }; + }).toList(); + + return DropdownButton( + value: themeModeProvider.value, + items: ThemeMode.values.map((e) { + return DropdownMenuItem( + value: e, + child: Row( + children: [ + Icon(icons[e.index]), + const SizedBox(width: 8.0), + Text(appL10n.flThemeMode(e.name)), + ], + ), + ); + }).toList(), + onChanged: (value) => themeModeProvider.value = value!, + ); + }), + ); + } +} diff --git a/lib/interfaces/libs/widgets/common_network_image.dart b/lib/interfaces/libs/widgets/media/common_network_image.dart similarity index 100% rename from lib/interfaces/libs/widgets/common_network_image.dart rename to lib/interfaces/libs/widgets/media/common_network_image.dart diff --git a/lib/interfaces/libs/widgets/custom_camera.dart b/lib/interfaces/libs/widgets/media/custom_camera.dart similarity index 79% rename from lib/interfaces/libs/widgets/custom_camera.dart rename to lib/interfaces/libs/widgets/media/custom_camera.dart index c5e7873..e0acc63 100644 --- a/lib/interfaces/libs/widgets/custom_camera.dart +++ b/lib/interfaces/libs/widgets/media/custom_camera.dart @@ -11,37 +11,7 @@ import "package:dicoding_story_fl/libs/extensions.dart"; typedef CustomCameraResultCallback = void Function(XFile result); -/// {@template dicoding_story_fl.interfaces.ui.CustomCameraDelegate} -/// Camera configuration. -/// {@endtemplate} -class CustomCameraDelegate extends Equatable { - /// {@macro dicoding_story_fl.interfaces.ui.CustomCameraDelegate} - const CustomCameraDelegate({ - this.resolutionPreset = ResolutionPreset.medium, - this.enableAudio = true, - this.imageFormatGroup, - }); - - /// Affect the quality of video recording and image capture: - /// - /// A preset is treated as a target resolution, and exact values are not - /// guaranteed. Platform implementations may fall back to a higher or lower - /// resolution if a specific preset is not available. - final ResolutionPreset resolutionPreset; - - /// Whether to include audio when recording a video. - final bool enableAudio; - - /// The [ImageFormatGroup] describes the output of the raw image format. - /// - /// When null the imageFormat will fallback to the platforms default. - final ImageFormatGroup? imageFormatGroup; - - @override - List get props => [resolutionPreset, enableAudio, imageFormatGroup]; -} - -/// {@template dicoding_story_fl.interfaces.ui.CustomCamera} +/// {@template ds.interfaces.libs.widgets.CustomCamera} /// Custom camera widget. /// /// Note that this widget will expand to fill available space. So make sure @@ -50,7 +20,7 @@ class CustomCameraDelegate extends Equatable { /// Video record not supported yet. /// {@endtemplate} class CustomCamera extends StatefulWidget { - /// {@macro dicoding_story_fl.interfaces.ui.CustomCamera} + /// {@macro ds.interfaces.libs.widgets.CustomCamera} CustomCamera( this.cameras, { super.key, @@ -92,23 +62,23 @@ class CustomCameraState extends State // STATES AND METHODS // --------------------------------------------------------------------------- - CameraController? _camController; + CameraController? _controller; /// Controller for camera widget. /// - /// Initialize it with [setCamController] method. - CameraController? get camController => _camController; + /// Initialize it with [setController] method. + CameraController? get controller => _controller; - /// [CameraDescription] from [camController]. - CameraDescription? get selectedCam => camController?.description; + /// [CameraDescription] from [controller]. + CameraDescription? get selectedCamera => controller?.description; - /// Indicates [camController] is initialized and ready to use. - bool get isCamInitialized => camController?.value.isInitialized ?? false; + /// Indicates [controller] is initialized and ready to use. + bool get isCameraInitialized => controller?.value.isInitialized ?? false; - /// Indicates [camController] is switching camera. Don't misinterpret this as + /// Indicates [controller] is switching camera. Don't misinterpret this as /// a condition that the camera has been switched. /// - /// Triggered by [setCamController] method. + /// Triggered by [setController] method. bool isSwitchingCam = false; Object? _error; @@ -120,11 +90,11 @@ class CustomCameraState extends State /// [error] trace. StackTrace? get trace => _trace; - /// Set [camController]. Will [setState] if [mounted]. + /// Set [controller]. Will [setState] if [mounted]. /// /// If [camera] already used, then won't do anything. - Future setCamController(CameraDescription camera) async { - if (selectedCam == camera && error == null) return; + Future setController(CameraDescription camera) async { + if (selectedCamera == camera && error == null) return; final newController = CameraController( camera, @@ -133,23 +103,23 @@ class CustomCameraState extends State imageFormatGroup: delegate.imageFormatGroup, ); - void initProcess() { + void preProcess() { isSwitchingCam = true; _error = null; _trace = null; } - (mounted) ? setState(initProcess) : initProcess(); + (mounted) ? setState(preProcess) : preProcess(); try { // for debugging camera switching if (kDebugMode) await Future.delayed(const Duration(seconds: 1)); await newController.initialize(); - await camController?.dispose(); + await controller?.dispose(); void postProcess() { - _camController = newController; + _controller = newController; isSwitchingCam = false; } @@ -158,7 +128,7 @@ class CustomCameraState extends State // dispose [newController] on error. newController.dispose(); - late final String? msg; + String? msg; if (err is CameraException) { msg = err.description ?? "Failed to initialize camera"; @@ -167,36 +137,36 @@ class CustomCameraState extends State final exception = err.toAppException(message: msg, trace: trace); kLogger.e( - "CustomCameraWidgetState.setCamController", + "CustomCameraState.setController", error: exception, stackTrace: exception.trace, ); - void postProcess() { + void errorPostProcess() { isSwitchingCam = false; _error = exception; _trace = exception.trace; } - (mounted) ? setState(postProcess) : postProcess(); + (mounted) ? setState(errorPostProcess) : errorPostProcess(); } } /// File result from camera capture/recording. /// /// Video are not supported yet. - XFile? camResult; + XFile? cameraResult; /// Take picture from camera. /// - /// Success take will stored in [camResult]. + /// Success take will stored in [cameraResult]. Future takePicture() async { try { - final image = await camController?.takePicture(); + final image = await controller?.takePicture(); if (image == null) return null; - setState(() => camResult = image); + setState(() => cameraResult = image); return image; } on CameraException catch (err, trace) { final exception = err.toAppException( @@ -205,7 +175,7 @@ class CustomCameraState extends State ); kLogger.e( - "CustomCameraWidgetState.takePicture", + "CustomCameraState.takePicture", error: exception, stackTrace: exception.trace, ); @@ -218,14 +188,14 @@ class CustomCameraState extends State /// (Commonly on mobile to switch between front and back camera). /// /// For more switching methods, try use [DropdownButton] with - /// [setCamController]. - Future switchCam() async { + /// [setController]. + Future switchCamera() async { if (cameras.length != 2) return; - if (camController?.description == cameras.first) { - setCamController(cameras[1]); + if (controller?.description == cameras.first) { + setController(cameras[1]); } else { - setCamController(cameras.first); + setController(cameras.first); } } @@ -235,14 +205,14 @@ class CustomCameraState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (!isCamInitialized) return; + if (!isCameraInitialized) return; switch (state) { case AppLifecycleState.inactive: - camController?.dispose(); + controller?.dispose(); break; case AppLifecycleState.resumed: - if (selectedCam != null) setCamController(selectedCam!); + if (selectedCamera != null) setController(selectedCamera!); break; default: } @@ -254,13 +224,13 @@ class CustomCameraState extends State WidgetsBinding.instance.addObserver(this); - if (cameras.isNotEmpty) setCamController(cameras.first); + if (cameras.isNotEmpty) setController(cameras.first); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - camController?.dispose(); + controller?.dispose(); super.dispose(); } @@ -270,15 +240,15 @@ class CustomCameraState extends State final theme = context.theme; final textTheme = theme.textTheme; - final isSwitchCamDisable = cameras.length < 2 && isCamInitialized; + final isSwitchCamDisable = cameras.length < 2 && isCameraInitialized; - return camResult != null + return cameraResult != null ? Column( children: [ Expanded( child: SizedBox( width: double.infinity, - child: ImageFromXFile(camResult!, fit: BoxFit.cover), + child: ImageFromXFile(cameraResult!, fit: BoxFit.cover), ), ), Container( @@ -288,11 +258,11 @@ class CustomCameraState extends State child: Row( children: [ TextButton( - onPressed: () => setState(() => camResult = null), + onPressed: () => setState(() => cameraResult = null), child: const Text("Retry"), ), TextButton( - onPressed: () => onAcceptResult?.call(camResult!), + onPressed: () => onAcceptResult?.call(cameraResult!), child: const Text("Ok"), ), ].map((e) => Expanded(child: e)).toList(), @@ -306,8 +276,8 @@ class CustomCameraState extends State child: Container( height: double.infinity, color: theme.scaffoldBackgroundColor, - child: isCamInitialized && !isSwitchingCam - ? camController?.buildPreview() + child: isCameraInitialized && !isSwitchingCam + ? controller?.buildPreview() : error != null ? SizedErrorWidget.expand(error: error, trace: trace) : Center( @@ -331,7 +301,7 @@ class CustomCameraState extends State MenuAnchor( menuChildren: cameras.map((e) { return MenuItemButton( - onPressed: () => setCamController(e), + onPressed: () => setController(e), child: Text( '${e.lensDirection.name.capitalize} Camera - ${e.name.split('<').first}', ), @@ -364,3 +334,33 @@ class CustomCameraState extends State ); } } + +/// {@template ds.interfaces.libs.widgets.CustomCameraDelegate} +/// Camera configuration. +/// {@endtemplate} +class CustomCameraDelegate extends Equatable { + /// {@macro ds.interfaces.libs.widgets.CustomCameraDelegate} + const CustomCameraDelegate({ + this.resolutionPreset = ResolutionPreset.medium, + this.enableAudio = true, + this.imageFormatGroup, + }); + + /// Affect the quality of video recording and image capture: + /// + /// A preset is treated as a target resolution, and exact values are not + /// guaranteed. Platform implementations may fall back to a higher or lower + /// resolution if a specific preset is not available. + final ResolutionPreset resolutionPreset; + + /// Whether to include audio when recording a video. + final bool enableAudio; + + /// The [ImageFormatGroup] describes the output of the raw image format. + /// + /// When null the imageFormat will fallback to the platforms default. + final ImageFormatGroup? imageFormatGroup; + + @override + List get props => [resolutionPreset, enableAudio, imageFormatGroup]; +} diff --git a/lib/interfaces/libs/widgets/image_from_x_file.dart b/lib/interfaces/libs/widgets/media/image_from_x_file.dart similarity index 100% rename from lib/interfaces/libs/widgets/image_from_x_file.dart rename to lib/interfaces/libs/widgets/media/image_from_x_file.dart diff --git a/lib/interfaces/modules/routes/root/profile_route.dart b/lib/interfaces/modules/routes/root/profile_route.dart index 182ed41..230f241 100644 --- a/lib/interfaces/modules/routes/root/profile_route.dart +++ b/lib/interfaces/modules/routes/root/profile_route.dart @@ -67,70 +67,8 @@ class _ProfileRouteScreen extends StatelessWidget { // others section Text(appL10n.other(2), style: sectionHeaderTextStyle), - ListTile( - leading: const Icon(Icons.color_lens_outlined), - title: Text(appL10n.appTheme), - trailing: Builder(builder: (context) { - final themeModeProvider = context.watch(); - - final icons = ThemeMode.values.map((e) { - return switch (e) { - ThemeMode.system => Icons.settings_outlined, - ThemeMode.light => Icons.light_mode_outlined, - ThemeMode.dark => Icons.dark_mode_outlined, - }; - }).toList(); - - return DropdownButton( - value: themeModeProvider.value, - items: ThemeMode.values.map((e) { - return DropdownMenuItem( - value: e, - child: Row( - children: [ - Icon(icons[e.index]), - const SizedBox(width: 8.0), - Text(appL10n.flThemeMode(e.name)), - ], - ), - ); - }).toList(), - onChanged: (value) => themeModeProvider.value = value!, - ); - }), - ), - ListTile( - leading: const Icon(Icons.language_outlined), - title: Text(appL10n.language), - trailing: Builder(builder: (context) { - final localeProvider = context.watch(); - - String localeString(String localeString) { - return switch (localeString) { - "en" => "English", - "id" => "Bahasa Indonesia", - _ => appL10n.flThemeMode(ThemeMode.system.name), - }; - } - - return DropdownButton( - value: localeProvider.value, - items: [ - DropdownMenuItem( - value: null, - child: Text(localeString("system")), - ), - ...AppL10n.supportedLocales.map((e) { - return DropdownMenuItem( - value: e, - child: Text(localeString("$e")), - ); - }) - ], - onChanged: (value) => localeProvider.value = value, - ); - }), - ), + const ThemeListTile(), + const LocaleListTile(), const AppAboutListTile(), ], ), diff --git a/lib/interfaces/modules/routes/root/stories/routes/story_detail_route.dart b/lib/interfaces/modules/routes/root/stories/routes/story_detail_route.dart index d43fe61..109479d 100644 --- a/lib/interfaces/modules/routes/root/stories/routes/story_detail_route.dart +++ b/lib/interfaces/modules/routes/root/stories/routes/story_detail_route.dart @@ -29,6 +29,8 @@ class _StoryDetailRouteScreen extends StatelessWidget { final String storyId; + double get _wideLayoutMinWidth => 800; + @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( @@ -57,48 +59,51 @@ class _StoryDetailRouteScreen extends StatelessWidget { if (story == null) return loadingWidget; return LayoutBuilder(builder: (context, constraints) { - if (constraints.maxWidth < 720) { - return _StoryDetailRouteScreenSmall(story); + if (constraints.minWidth >= _wideLayoutMinWidth) { + return _StoryDetailRouteScreenWide(story); } - return _StoryDetailRouteScreenWide(story); + return _StoryDetailRouteScreenSmall(story); }); }, ); } } -/// Expands image on dialog to show image with [BoxFit.contain]. -void _showImageDialog(BuildContext context, ImageProvider image) { - showDialog( - context: context, - builder: (context) { - return Dialog( - clipBehavior: Clip.hardEdge, - child: Stack( - fit: StackFit.expand, - children: [ - Image(image: image, fit: BoxFit.contain), - // - Padding( - padding: const EdgeInsets.all(16.0), - child: Align( - alignment: Alignment.topRight, - child: IconButton.filledTonal( - icon: const Icon(Icons.close), - tooltip: - MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () => Navigator.maybePop(context), +mixin _StoryDetailRouteScreenHelperMixin { + /// Expands image on dialog to show image with [BoxFit.contain]. + void _showImageDialog(BuildContext context, ImageProvider image) { + showDialog( + context: context, + builder: (context) { + return Dialog( + clipBehavior: Clip.hardEdge, + child: Stack( + fit: StackFit.expand, + children: [ + Image(image: image, fit: BoxFit.contain), + // + Padding( + padding: const EdgeInsets.all(16.0), + child: Align( + alignment: Alignment.topRight, + child: IconButton.filledTonal( + icon: const Icon(Icons.close), + tooltip: + MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => Navigator.maybePop(context), + ), ), ), - ), - ], - ), - ); - }); + ], + ), + ); + }); + } } -class _StoryDetailRouteScreenSmall extends StatelessWidget { +class _StoryDetailRouteScreenSmall extends StatelessWidget + with _StoryDetailRouteScreenHelperMixin { const _StoryDetailRouteScreenSmall(this.story); final Story story; @@ -160,7 +165,8 @@ class _StoryDetailRouteScreenSmall extends StatelessWidget { } } -class _StoryDetailRouteScreenWide extends StatelessWidget { +class _StoryDetailRouteScreenWide extends StatelessWidget + with _StoryDetailRouteScreenHelperMixin { const _StoryDetailRouteScreenWide(this.story); final Story story; @@ -178,7 +184,7 @@ class _StoryDetailRouteScreenWide extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - flex: 8, + flex: 10, child: Stack( fit: StackFit.expand, children: [ @@ -210,7 +216,7 @@ class _StoryDetailRouteScreenWide extends StatelessWidget { ), const VerticalDivider(width: 2.0, thickness: 2.0), Flexible( - flex: 10, + flex: 8, child: SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column(