6177214e-ce7c-49e3-99de-ff9721b26f63 — Commit ae4f7e54

AuthorMikkel Thygesen<mikkelet@gmail.com>
Date2026-02-16 15:11:27 +0100
added new_feature script

Changed files

comwell_key_app/lib/domain/models/app_error.dart   |  19 +++
 .../lib/presentation/base/base_cubit.dart          |  21 +++
 .../navigation/pages/bottom_sheet_page.dart        |  33 ++++
 .../presentation/navigation/pages/dialog_page.dart |  31 ++++
 .../pages/draggable_bottom_sheet_page.dart         |  49 ++++++
 .../transitions/slide_in_transition.dart           |  81 ++++++++++
 .../transitions/slide_up_transition.dart           |  21 +++
 comwell_key_app/scripts/dart/new_feature.dart      | 166 +++++++++++++++++++++
 comwell_key_app/scripts/new_feature.sh             |  17 +++
 9 files changed, 438 insertions(+)

Diff

diff --git a/comwell_key_app/lib/domain/models/app_error.dart b/comwell_key_app/lib/domain/models/app_error.dart
new file mode 100644
index 00000000..df071517
--- /dev/null
+++ b/comwell_key_app/lib/domain/models/app_error.dart
@@ -0,0 +1,19 @@
+sealed class AppError {
+ const AppError();
+
+ static const none = None();
+
+ bool get isError => this is! None;
+
+ factory AppError.unknown(String message) => UnknownError(message);
+}
+
+final class None extends AppError {
+ const None() : super();
+}
+
+final class UnknownError extends AppError {
+ final String message;
+
+ const UnknownError(this.message);
+}
diff --git a/comwell_key_app/lib/presentation/base/base_cubit.dart b/comwell_key_app/lib/presentation/base/base_cubit.dart
new file mode 100644
index 00000000..dcd9b8fd
--- /dev/null
+++ b/comwell_key_app/lib/presentation/base/base_cubit.dart
@@ -0,0 +1,21 @@
+import 'package:bloc/bloc.dart';
+import 'package:flutter/foundation.dart';
+import 'package:sentry_flutter/sentry_flutter.dart';
+
+class BaseCubit<T> extends Cubit<T> {
+ BaseCubit(super.initialState);
+
+ void safeEmit(T state) {
+ if (isClosed) return;
+ emit(state);
+ }
+
+ void logError(Object e, StackTrace st) {
+ if (kDebugMode) {
+ print("qqq $e");
+ print("qqq $st");
+ } else {
+ Sentry.captureException(e);
+ }
+ }
+}
diff --git a/comwell_key_app/lib/presentation/navigation/pages/bottom_sheet_page.dart b/comwell_key_app/lib/presentation/navigation/pages/bottom_sheet_page.dart
new file mode 100644
index 00000000..b711945c
--- /dev/null
+++ b/comwell_key_app/lib/presentation/navigation/pages/bottom_sheet_page.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+
+// package:flutter/bottom_sheet.dart:30
+const double _defaultScrollControlDisabledMaxHeightRatio = 9.0 / 16.0;
+
+class BottomSheetPage<T> extends Page<T> {
+ final Widget child;
+ final double? maxHeightFraction;
+ final bool isScrollControlled;
+
+ const BottomSheetPage({
+ required this.child,
+ this.maxHeightFraction,
+ this.isScrollControlled = false,
+ super.key,
+ });
+
+ @override
+ Route<T> createRoute(BuildContext context) => ModalBottomSheetRoute<T>(
+ settings: this,
+ isScrollControlled: isScrollControlled,
+ useSafeArea: true,
+ isDismissible: true,
+ showDragHandle: false,
+ scrollControlDisabledMaxHeightRatio:
+ maxHeightFraction ?? _defaultScrollControlDisabledMaxHeightRatio,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
+ ),
+ clipBehavior: Clip.antiAlias,
+ builder: (context) => child,
+ );
+}
diff --git a/comwell_key_app/lib/presentation/navigation/pages/dialog_page.dart b/comwell_key_app/lib/presentation/navigation/pages/dialog_page.dart
new file mode 100644
index 00000000..7508983c
--- /dev/null
+++ b/comwell_key_app/lib/presentation/navigation/pages/dialog_page.dart
@@ -0,0 +1,31 @@
+import 'package:flutter/material.dart';
+
+class DialogPage extends Page<dynamic> {
+ final Widget child;
+ final bool dismissable;
+
+ const DialogPage({required this.child, this.dismissable = true, super.key});
+
+ @override
+ Route<dynamic> createRoute(BuildContext context) {
+ return DialogRoute(
+ settings: this,
+ useSafeArea: true,
+ context: context,
+ barrierDismissible: dismissable,
+ builder: (context) {
+ return PopScope(
+ canPop: dismissable,
+ child: Dialog(
+ clipBehavior: Clip.antiAlias,
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadiusGeometry.circular(32),
+ ),
+ child: child,
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/comwell_key_app/lib/presentation/navigation/pages/draggable_bottom_sheet_page.dart b/comwell_key_app/lib/presentation/navigation/pages/draggable_bottom_sheet_page.dart
new file mode 100644
index 00000000..cb9cd5b0
--- /dev/null
+++ b/comwell_key_app/lib/presentation/navigation/pages/draggable_bottom_sheet_page.dart
@@ -0,0 +1,49 @@
+import 'package:flutter/material.dart';
+
+class DraggableBottomSheetPage<T> extends Page<T> {
+ final Widget Function(BuildContext context, ScrollController scrollController) builder;
+ final bool showDragHandle;
+ final bool useSafeArea;
+ final double maxChildSize;
+ final double minChildSize;
+ final double initialChildSize;
+
+ const DraggableBottomSheetPage({
+ required this.builder,
+ this.showDragHandle = false,
+ this.useSafeArea = true,
+ this.maxChildSize = 1,
+ this.minChildSize = 0.5,
+ this.initialChildSize = 0.6,
+ super.key,
+ });
+
+ @override
+ Route<T> createRoute(BuildContext context) => ModalBottomSheetRoute<T>(
+ settings: this,
+ isScrollControlled: false,
+ showDragHandle: showDragHandle,
+ useSafeArea: useSafeArea,
+ isDismissible: true,
+ enableDrag: true,
+ scrollControlDisabledMaxHeightRatio: 1.0,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
+ ),
+ clipBehavior: Clip.antiAlias,
+ builder: (context) {
+ return DraggableScrollableSheet(
+ initialChildSize: initialChildSize,
+ minChildSize: minChildSize,
+ maxChildSize: maxChildSize,
+ shouldCloseOnMinExtent: true,
+ expand: false,
+ snap: true,
+ snapSizes: [initialChildSize],
+ builder: (BuildContext context, ScrollController scrollController) {
+ return builder(context, scrollController);
+ },
+ );
+ },
+ );
+}
diff --git a/comwell_key_app/lib/presentation/navigation/transitions/slide_in_transition.dart b/comwell_key_app/lib/presentation/navigation/transitions/slide_in_transition.dart
new file mode 100644
index 00000000..119c449e
--- /dev/null
+++ b/comwell_key_app/lib/presentation/navigation/transitions/slide_in_transition.dart
@@ -0,0 +1,81 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/cupertino.dart' as cupertino;
+import 'package:go_router/go_router.dart';
+
+// ignore: non_constant_identifier_names
+Page<void> SlideInTransition({
+ required GoRouterState state,
+ required Widget child,
+ bool withEnterAnimation = true,
+ bool withExitAnimation = true,
+}) {
+ if (Platform.isIOS) {
+ return cupertino.CupertinoPage<void>(
+ key: state.pageKey,
+ child: child,
+ );
+ }
+ return CustomTransitionPage(
+ key: state.pageKey,
+ transitionDuration: const Duration(milliseconds: 300),
+ reverseTransitionDuration: const Duration(milliseconds: 300),
+ child: child,
+ transitionsBuilder: (context, enterAnimation, exitAnimation, child) {
+ final curvedEnterAnimation = CurvedAnimation(
+ parent: enterAnimation,
+ curve: Curves.easeInOutCubic,
+ );
+ final curvedExitAnimation = CurvedAnimation(
+ parent: exitAnimation,
+ curve: Curves.easeInOutCubic,
+ );
+
+ Widget result = child;
+
+ // Exit animation (when a new page is pushed on top of this one)
+ if (withExitAnimation) {
+ result = SlideTransition(
+ position: Tween<Offset>(
+ begin: Offset.zero,
+ end: const Offset(-1.0, 0.0),
+ ).animate(curvedExitAnimation),
+ child: result,
+ );
+ }
+
+ // Enter animation (when this page enters or when going back from this page)
+ if (withEnterAnimation) {
+ result = SlideTransition(
+ position: Tween<Offset>(
+ begin: const Offset(1.0, 0.0),
+ end: Offset.zero,
+ ).animate(curvedEnterAnimation),
+ child: result,
+ );
+ } else {
+ // Only animate when going back (reverse), not when entering (forward)
+ result = AnimatedBuilder(
+ animation: enterAnimation,
+ builder: (context, animatedChild) {
+ if (enterAnimation.status == AnimationStatus.reverse) {
+ final offset = Tween<Offset>(
+ begin: const Offset(1.0, 0.0),
+ end: Offset.zero,
+ ).evaluate(curvedEnterAnimation);
+ return FractionalTranslation(
+ translation: offset,
+ child: animatedChild,
+ );
+ }
+ return animatedChild!;
+ },
+ child: result,
+ );
+ }
+
+ return result;
+ },
+ );
+}
diff --git a/comwell_key_app/lib/presentation/navigation/transitions/slide_up_transition.dart b/comwell_key_app/lib/presentation/navigation/transitions/slide_up_transition.dart
new file mode 100644
index 00000000..76e450c2
--- /dev/null
+++ b/comwell_key_app/lib/presentation/navigation/transitions/slide_up_transition.dart
@@ -0,0 +1,21 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+
+// ignore: non_constant_identifier_names
+Page<dynamic> SlideUpTransition({required GoRouterState state, required Widget child}) {
+ return CustomTransitionPage(
+ key: state.pageKey,
+ transitionDuration: const Duration(milliseconds: 200),
+ child: child,
+ transitionsBuilder: (context, animation, secondaryAnimation, child) {
+ Widget result = child;
+ return SlideTransition(
+ position: Tween<Offset>(
+ begin: const Offset(0, 0.8),
+ end: Offset.zero,
+ ).animate(animation),
+ child: FadeTransition(opacity: animation, child: result),
+ );
+ },
+ );
+}
diff --git a/comwell_key_app/scripts/dart/new_feature.dart b/comwell_key_app/scripts/dart/new_feature.dart
new file mode 100644
index 00000000..3ad9fae4
--- /dev/null
+++ b/comwell_key_app/scripts/dart/new_feature.dart
@@ -0,0 +1,166 @@
+import 'package:change_case/change_case.dart';
+
+import 'utils.dart';
+
+const packageName = "comwell_key_app";
+
+Future<void> main(List<String> args) async {
+ // create files
+ if (args.isEmpty) throw Exception("Missing args");
+ if (args.length > 1) throw Exception("Too many arguments");
+ final featureName = args[0];
+ final className = featureName.toPascalCase();
+ final snakeCase = featureName.toSnakeCase();
+ final blocFile = await createFile(
+ "lib/presentation/screens/$featureName/bloc/${featureName}_cubit.dart",
+ );
+ final stateFile = await createFile(
+ "lib/presentation/screens/$featureName/bloc/${featureName}_state.dart",
+ );
+ final screenFile = await createFile(
+ "lib/presentation/screens/$featureName/${featureName}_screen.dart",
+ );
+ final routeFile = await createFile(
+ "lib/presentation/screens/$featureName/${featureName}_route.dart",
+ );
+
+ // write files
+ await writeToFile(screenFile, screenTemplate(snakeCase, className));
+ await writeToFile(stateFile, stateTemplate(snakeCase, className));
+ await writeToFile(blocFile, blocTemplate(snakeCase, className));
+ await writeToFile(routeFile, routeTemplate(snakeCase, className));
+ print("$featureName created successfully");
+}
+
+String screenTemplate(String snakeCase, String className) {
+ final cubitName = "${className}Cubit";
+ final screenName = "${className}Screen";
+ final stateName = "${className}State";
+ return """
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:$packageName/presentation/screens/$snakeCase/bloc/${snakeCase}_cubit.dart';
+import 'package:$packageName/presentation/screens/$snakeCase/bloc/${snakeCase}_state.dart';
+
+class $screenName extends StatelessWidget {
+ const $screenName({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocBuilder<$cubitName, $stateName>(
+ builder: (context, state) {
+ final cubit = context.read<$cubitName>();
+ return MultiBlocListener(
+ listeners: [
+ BlocListener<$cubitName, $stateName>(
+ listenWhen: (prev, curr) =>
+ prev.isLoading && !curr.isLoading && curr.error.isError,
+ listener: (context, state) {
+ // context.showErrorSnackBar(state.errorMessage);
+ },
+ )
+ ],
+ child: Scaffold(
+ appBar: AppBar(),
+ body: Center(
+ child: Column(
+ children: [
+ Text("$className"),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+"""
+ .trim();
+}
+
+String stateTemplate(String snakeCase, String className) {
+ final stateName = "${className}State";
+ return """
+import 'package:comwell_key_app/domain/models/app_error.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part '../../../../.generated/presentation/screens/$snakeCase/bloc/${snakeCase}_state.freezed.dart';
+
+@freezed
+abstract class $stateName with _\$$stateName {
+ const factory $stateName({
+ @Default(false) bool isLoading,
+ @Default(AppError.none) AppError error,
+ }) = _$stateName;
+
+ TestState loading() => copyWith(isLoading: true, error: AppError.none);
+}
+
+"""
+ .trim();
+}
+
+String blocTemplate(String snakeCase, String className) {
+ final cubitName = "${className}Cubit";
+ final stateName = "${className}State";
+ return """
+import 'package:comwell_key_app/domain/models/app_error.dart';
+import 'package:$packageName/presentation/base/base_cubit.dart';
+import 'package:$packageName/presentation/screens/$snakeCase/bloc/${snakeCase}_state.dart';
+
+class $cubitName extends BaseCubit<$stateName> {
+ $cubitName() : super(const $stateName()) {
+ init();
+ }
+
+ Future<void> init() async {
+ try {
+ safeEmit(state.loading());
+ // await Function();
+ } catch (e, st) {
+ logError(e, st);
+ safeEmit(state.copyWith(error: AppError.unknown(e.toString())));
+ } finally {
+ safeEmit(state.copyWith(isLoading: false));
+ }
+ }
+}
+ """
+ .trim();
+}
+
+String routeTemplate(String snakeCase, String className) {
+ final routeName = "${className}Route";
+ final pathName = "static const ${className.toCamelCase()} = \"/${className.toKebabCase()}\";";
+ final cubitName = "${className}Cubit";
+ final screenName = "${className}Screen";
+
+ return """
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:go_router/go_router.dart';
+import 'package:$packageName/presentation/navigation/transitions/slide_in_transition.dart';
+import 'package:$packageName/presentation/screens/$snakeCase/bloc/${snakeCase}_cubit.dart';
+import 'package:$packageName/presentation/screens/$snakeCase/${snakeCase}_screen.dart';
+
+part '../../../.generated/presentation/screens/$snakeCase/${snakeCase}_route.g.dart';
+
+@TypedGoRoute<$routeName>(
+ path: "/${className.toKebabCase()}", // add me to AppRoutes: $pathName
+)
+class $routeName extends GoRouteData with \$$routeName {
+
+ @override
+ Page<void> buildPage(BuildContext context, GoRouterState state) {
+ return SlideInTransition(
+ state: state,
+ child: BlocProvider(
+ create: (context) => $cubitName(),
+ child: const $screenName(),
+ ),
+ );
+ }
+}"""
+ .trim();
+}
diff --git a/comwell_key_app/scripts/new_feature.sh b/comwell_key_app/scripts/new_feature.sh
new file mode 100644
index 00000000..83577c31
--- /dev/null
+++ b/comwell_key_app/scripts/new_feature.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+FEATURE_NAME="${1:-}"
+if [[ -z "${FEATURE_NAME}" ]]; then
+ echo "Usage: $(basename "$0") <feature_name>"
+ echo "Example: $(basename "$0") hotel_overview"
+ exit 2
+fi
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+
+cd "${REPO_ROOT}"
+
+dart run scripts/dart/new_feature.dart "${FEATURE_NAME}"
+bash scripts/gen.sh