diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c5e9168..99fbf95 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -62,7 +62,8 @@ jobs: - name: 📦 Get dependencies run: flutter pub get - web-deploy: + web-github-pages: + name: 🚀 Web Deploy on Github Pages needs: dependencies runs-on: macos-latest @@ -87,9 +88,20 @@ jobs: dart run build_runner build -d flutter gen-l10n + - name: 🏗️ Release build number setup + id: build-number + uses: onyxmueller/build-tag-number@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + prefix: build-number--web--github-action + - name: 🏗️ Build web release + env: + BASE_HREF: "/dicoding_story_fl/" + BUILD_NUMBER: ${{ steps.build-number.outputs.build_number }} + WEB_RENDERER: canvaskit run: | - flutter build web --release --base-href "/dicoding_story_fl/" --web-renderer canvaskit + flutter build web --release --base-href ${{ env.BASE_HREF }} --build-number ${{ env.BUILD_NUMBER }} --build-number ${{ env.BUILD_NUMBER }} --web-renderer ${{ env.WEB_RENDERER }} - name: 🚀 Deploy to Github Pages uses: peaceiris/actions-gh-pages@v3 @@ -98,7 +110,8 @@ jobs: publish_dir: ./build/web publish_branch: web-release - gh-apk-release: + apk-github-release: + name: 🚀 Apk Release on Github Release needs: dependencies runs-on: macos-latest @@ -126,8 +139,18 @@ jobs: echo "${{ secrets.ANDROID_RELEASE_KEY_BASE64 }}" | base64 --decode > secrets/android-release-key.jks echo "${{ secrets.ANDROID_KEY_PROPS_BASE64 }}" | base64 --decode > android/key.properties + - name: 🏗️ Release build number setup + id: build-number + uses: onyxmueller/build-tag-number@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + prefix: build-number--apk--github-release + - name: 🏗️ Build APK release - run: flutter build apk --release + env: + BUILD_NUMBER: ${{ steps.build-number.outputs.build_number }} + run: | + flutter build apk --release --build-number ${{ env.BUILD_NUMBER }} - name: 📝 Release build uses: ncipollo/release-action@v1 diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 0592eb8..de67c80 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -1,3 +1,4 @@ export "entities/app_exception_entity.dart"; +export "entities/location_data_entity.dart"; export "entities/story_entity.dart"; export "entities/user_entity.dart"; diff --git a/lib/domain/entities/location_data_entity.dart b/lib/domain/entities/location_data_entity.dart new file mode 100644 index 0000000..c6b503a --- /dev/null +++ b/lib/domain/entities/location_data_entity.dart @@ -0,0 +1,42 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "location_data_entity.freezed.dart"; + +@Freezed(copyWith: true) +class LocationData with _$LocationData { + const factory LocationData({ + /// Stands for latitude. + required double lat, + + /// Stands for longitude. + required double lon, + LocationPlaceData? placeData, + }) = _LocationData; + + const LocationData._(); + + /// [lat], [lon] formatted as "lat, lon". + String get latLon => "$lat, $lon"; + + /// Get address from [placeData]. + /// + /// return [latLon] if address not found. + String get address => placeData?.address ?? latLon; + + /// Get display name from [placeData]. + /// + /// return [address] if display name not found. + String get displayName => placeData?.displayName ?? address; +} + +/// [g-maps-places-api]: https://developers.google.com/maps/documentation/places/web-service +/// +/// [Google Maps Places API data][g-maps-places-api]. +@Freezed(copyWith: true) +class LocationPlaceData with _$LocationPlaceData { + const factory LocationPlaceData({ + required String id, + String? address, + String? displayName, + }) = _LocationPlaceData; +} diff --git a/lib/domain/entities/story_entity.dart b/lib/domain/entities/story_entity.dart index 9aac5b9..903e36e 100644 --- a/lib/domain/entities/story_entity.dart +++ b/lib/domain/entities/story_entity.dart @@ -1,12 +1,13 @@ -import "package:freezed_annotation/freezed_annotation.dart"; import "package:flutter/foundation.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; import "package:dicoding_story_fl/libs/decorators.dart"; +import "location_data_entity.dart"; part "story_entity.freezed.dart"; part "story_entity.g.dart"; -@freezed +@Freezed(copyWith: true) class Story with _$Story { const factory Story({ required String id, @@ -16,6 +17,9 @@ class Story with _$Story { @dateTimeJsonConverter required DateTime createdAt, double? lat, double? lon, + + /// Detailed location data from reverse geocoding. + @ignoreJsonSerializable LocationData? location, }) = _Story; factory Story.fromJson(Map json) => _$StoryFromJson(json); 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..8716fa9 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.dart b/lib/interfaces/modules/routes.dart index 2c86d61..498048b 100644 --- a/lib/interfaces/modules/routes.dart +++ b/lib/interfaces/modules/routes.dart @@ -124,12 +124,15 @@ class _RootShellRouteScreenNavDelegate { final String label; final Icon? activeIcon; final String routePath; + + /// Only be rendered when the current route matches the [routePath]. final Widget? floatingActionButton; } mixin _RootShellRouteScreenNavHelperMixin on Widget { double get minWidthForNavigationRail => 800.0; + /// Match [GoRouter] current route starts with the [navs] route path. int _getCurrentNavigationIndex( BuildContext context, List<_RootShellRouteScreenNavDelegate> navs, @@ -138,6 +141,19 @@ mixin _RootShellRouteScreenNavHelperMixin on Widget { return navs.indexWhere((e) => currentRoute.startsWith(e.routePath)); } + + /// Instead of matching the route starts like [_getCurrentNavigationIndex] + /// does, this method match the exact route path. + /// + /// If there is no matched route, it returns -1. + int _getActualRouteMatchIndex( + BuildContext context, + List<_RootShellRouteScreenNavDelegate> navs, + ) { + final currentRoute = GoRouterState.of(context).uri.path; + + return navs.indexWhere((e) => currentRoute == e.routePath); + } } class _RootShellRouteScreenBody extends StatelessWidget @@ -239,6 +255,7 @@ class _RootShellRouteScreenBottomNavBar extends StatelessWidget return BottomNavigationBarItem( icon: e.icon, label: e.label, + activeIcon: e.activeIcon, ); }).toList(), ); @@ -263,11 +280,13 @@ class _RootShellRouteScreenFAB extends StatelessWidget @override Widget build(BuildContext context) { - final currentNavIndex = _getCurrentNavigationIndex( + final currentNavIndex = _getActualRouteMatchIndex( context, navigationDelegates, ); + if (currentNavIndex == -1) return const SizedBox.shrink(); + return navigationDelegates[currentNavIndex].floatingActionButton ?? const SizedBox.shrink(); } 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( diff --git a/lib/libs/decorators.dart b/lib/libs/decorators.dart index da2daad..237ef91 100644 --- a/lib/libs/decorators.dart +++ b/lib/libs/decorators.dart @@ -1 +1,2 @@ export "decorators/date_time_json_converter_decorator.dart"; +export "decorators/ignore_json_serializable_decorator.dart"; diff --git a/lib/libs/decorators/date_time_json_converter_decorator.dart b/lib/libs/decorators/date_time_json_converter_decorator.dart index 4d1e909..49b162d 100644 --- a/lib/libs/decorators/date_time_json_converter_decorator.dart +++ b/lib/libs/decorators/date_time_json_converter_decorator.dart @@ -1,8 +1,13 @@ import "package:freezed_annotation/freezed_annotation.dart"; +/// {@template ds.libs.decorators.DateTimeJsonConverter} +/// Handle freezed json serialization for [DateTime] field. +/// {@endtemplate} const dateTimeJsonConverter = DateTimeJsonConverter(); +/// {@macro ds.libs.decorators.DateTimeJsonConverter} final class DateTimeJsonConverter implements JsonConverter { + /// {@macro ds.libs.decorators.DateTimeJsonConverter} const DateTimeJsonConverter(); @override diff --git a/lib/libs/decorators/ignore_json_serializable_decorator.dart b/lib/libs/decorators/ignore_json_serializable_decorator.dart new file mode 100644 index 0000000..a0078ac --- /dev/null +++ b/lib/libs/decorators/ignore_json_serializable_decorator.dart @@ -0,0 +1,7 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +/// Ignore freezed json_serializable on specific fields. +const ignoreJsonSerializable = JsonKey( + includeFromJson: false, + includeToJson: false, +);