diff --git a/example/lib/src/common/router/routes.dart b/example/lib/src/common/router/routes.dart index 4f1cc81..5e74d68 100644 --- a/example/lib/src/common/router/routes.dart +++ b/example/lib/src/common/router/routes.dart @@ -17,27 +17,30 @@ import 'package:flutter/material.dart'; import 'package:octopus/octopus.dart'; enum Routes with OctopusRoute { - signin('signin'), - signup('signup'), - home('home'), - shop('shop'), - catalog('catalog'), - category('category'), - product('product'), - productImage('product-img-dialog'), - basket('basket'), - checkout('checkout'), - favorites('favorites'), - gallery('gallery'), - profile('profile'), - settingsDialog('settings-dialog'), - aboutAppDialog('about-app-dialog'); + signin('signin', title: 'Sign-In'), + signup('signup', title: 'Sign-Up'), + home('home', title: 'Octopus'), + shop('shop', title: 'Shop'), + catalog('catalog', title: 'Catalog'), + category('category', title: 'Category'), + product('product', title: 'Product'), + productImage('product-img-dialog', title: 'Product Image'), + basket('basket', title: 'Basket'), + checkout('checkout', title: 'Checkout'), + favorites('favorites', title: 'Favorites'), + gallery('gallery', title: 'Gallery'), + profile('profile', title: 'Profile'), + settingsDialog('settings-dialog', title: 'Settings'), + aboutAppDialog('about-app-dialog', title: 'About Application'); - const Routes(this.name); + const Routes(this.name, {this.title}); @override final String name; + @override + final String? title; + @override Widget builder(BuildContext context, OctopusNode node) => switch (this) { Routes.signin => const SignInScreen(), diff --git a/example/lib/src/feature/authentication/widget/signin_screen.dart b/example/lib/src/feature/authentication/widget/signin_screen.dart index 611e854..111082b 100644 --- a/example/lib/src/feature/authentication/widget/signin_screen.dart +++ b/example/lib/src/feature/authentication/widget/signin_screen.dart @@ -39,147 +39,142 @@ class _SignInScreenState extends State bool _obscurePassword = true; @override - Widget build(BuildContext context) => Title( - title: 'Sign-In', - color: Theme.of(context).colorScheme.primary, - child: Scaffold( - body: SafeArea( - child: Center( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: EdgeInsets.symmetric( - horizontal: math.max(16, (constraints.maxWidth - 620) / 2), - ), - child: StateConsumer( - controller: _authenticationController, - buildWhen: (previous, current) => previous != current, - builder: (context, state, _) => Column( - children: [ - SizedBox( - height: 50, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(width: 50), - Text( - 'Sign-In', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .headlineLarge - ?.copyWith(height: 1), - ), - const SizedBox(width: 2), - Align( - alignment: Alignment.bottomRight, - child: IconButton( - icon: const Icon(Icons.casino), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: 48, - height: 48, - ), - tooltip: 'Generate password', - onPressed: state.isIdling - ? () { - if (_obscurePassword) - setState( - () => _obscurePassword = false, - ); - generatePassword(); - } - : null, + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: Center( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: math.max(16, (constraints.maxWidth - 620) / 2), + ), + child: StateConsumer( + controller: _authenticationController, + buildWhen: (previous, current) => previous != current, + builder: (context, state, _) => Column( + children: [ + SizedBox( + height: 50, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 50), + Text( + 'Sign-In', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .headlineLarge + ?.copyWith(height: 1), + ), + const SizedBox(width: 2), + Align( + alignment: Alignment.bottomRight, + child: IconButton( + icon: const Icon(Icons.casino), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 48, + height: 48, ), + tooltip: 'Generate password', + onPressed: state.isIdling + ? () { + if (_obscurePassword) + setState( + () => _obscurePassword = false, + ); + generatePassword(); + } + : null, ), - ], - ), - ), - const SizedBox(height: 32), - TextField( - focusNode: _usernameFocusNode, - enabled: state.isIdling, - maxLines: 1, - minLines: 1, - controller: _usernameController, - autocorrect: false, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email + ), ], - keyboardType: TextInputType.emailAddress, - inputFormatters: _usernameFormatters, - decoration: InputDecoration( - labelText: 'Username', - hintText: 'Enter your username', - helperText: '', - helperMaxLines: 1, - errorText: _usernameError ?? state.error, - errorMaxLines: 1, - prefixIcon: const Icon(Icons.person), - ), ), - const SizedBox(height: 8), - TextField( - focusNode: _passwordFocusNode, - enabled: state.isIdling, - maxLines: 1, - minLines: 1, - controller: _passwordController, - autocorrect: false, - obscureText: _obscurePassword, - maxLength: Config.passwordMaxLength, - autofillHints: const [AutofillHints.password], - keyboardType: TextInputType.visiblePassword, - decoration: InputDecoration( - labelText: 'Password', - hintText: 'Enter your password', - helperText: '', - helperMaxLines: 1, - errorText: _passwordError ?? state.error, - errorMaxLines: 1, - prefixIcon: const Icon(Icons.lock), - suffixIcon: IconButton( - icon: Icon(_obscurePassword - ? Icons.visibility - : Icons.visibility_off), - onPressed: () => setState( - () => _obscurePassword = !_obscurePassword), - ), - ), + ), + const SizedBox(height: 32), + TextField( + focusNode: _usernameFocusNode, + enabled: state.isIdling, + maxLines: 1, + minLines: 1, + controller: _usernameController, + autocorrect: false, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email + ], + keyboardType: TextInputType.emailAddress, + inputFormatters: _usernameFormatters, + decoration: InputDecoration( + labelText: 'Username', + hintText: 'Enter your username', + helperText: '', + helperMaxLines: 1, + errorText: _usernameError ?? state.error, + errorMaxLines: 1, + prefixIcon: const Icon(Icons.person), ), - const SizedBox(height: 32), - SizedBox( - height: 48, - child: AnimatedBuilder( - animation: _formChangedNotifier, - builder: (context, _) { - final formFilled = - _usernameController.text.length > 3 && - _passwordController.text.length >= - Config.passwordMinLength; - final signInCallback = - state.isIdling && formFilled - ? () => signIn(context) - : null; - final signUpCallback = - state.isIdling ? () => signUp(context) : null; - final key = ValueKey( - (signInCallback == null ? 0 : 1 << 1) | - (signUpCallback == null ? 0 : 1)); - return _SignInScreen$Buttons( - signIn: signInCallback, - signUp: signUpCallback, - key: key, - ); - }, + ), + const SizedBox(height: 8), + TextField( + focusNode: _passwordFocusNode, + enabled: state.isIdling, + maxLines: 1, + minLines: 1, + controller: _passwordController, + autocorrect: false, + obscureText: _obscurePassword, + maxLength: Config.passwordMaxLength, + autofillHints: const [AutofillHints.password], + keyboardType: TextInputType.visiblePassword, + decoration: InputDecoration( + labelText: 'Password', + hintText: 'Enter your password', + helperText: '', + helperMaxLines: 1, + errorText: _passwordError ?? state.error, + errorMaxLines: 1, + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility + : Icons.visibility_off), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), ), ), - ], - ), + ), + const SizedBox(height: 32), + SizedBox( + height: 48, + child: AnimatedBuilder( + animation: _formChangedNotifier, + builder: (context, _) { + final formFilled = + _usernameController.text.length > 3 && + _passwordController.text.length >= + Config.passwordMinLength; + final signInCallback = state.isIdling && formFilled + ? () => signIn(context) + : null; + final signUpCallback = + state.isIdling ? () => signUp(context) : null; + final key = ValueKey( + (signInCallback == null ? 0 : 1 << 1) | + (signUpCallback == null ? 0 : 1)); + return _SignInScreen$Buttons( + signIn: signInCallback, + signUp: signUpCallback, + key: key, + ); + }, + ), + ), + ], ), ), ), diff --git a/example/lib/src/feature/authentication/widget/signup_screen.dart b/example/lib/src/feature/authentication/widget/signup_screen.dart index 87bdc1f..dae46be 100644 --- a/example/lib/src/feature/authentication/widget/signup_screen.dart +++ b/example/lib/src/feature/authentication/widget/signup_screen.dart @@ -11,45 +11,41 @@ class SignUpScreen extends StatelessWidget { const SignUpScreen({super.key}); @override - Widget build(BuildContext context) => Title( - title: 'Sign-Up', - color: Theme.of(context).colorScheme.primary, - child: Scaffold( - body: SafeArea( - child: Center( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: EdgeInsets.symmetric( - horizontal: math.max(16, (constraints.maxWidth - 620) / 2), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - height: 50, - child: Text( - 'Sign-Up', - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .headlineLarge - ?.copyWith(height: 1), - ), + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: Center( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: math.max(16, (constraints.maxWidth - 620) / 2), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 50, + child: Text( + 'Sign-Up', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .headlineLarge + ?.copyWith(height: 1), ), - const SizedBox(height: 32), - const FormPlaceholder(), - const SizedBox(height: 32), - SizedBox( - height: 48, - child: _SignUpScreen$Buttons( - cancel: () => Navigator.pop(context), - signUp: null, - ), + ), + const SizedBox(height: 32), + const FormPlaceholder(), + const SizedBox(height: 32), + SizedBox( + height: 48, + child: _SignUpScreen$Buttons( + cancel: () => Navigator.pop(context), + signUp: null, ), - ], - ), + ), + ], ), ), ), diff --git a/example/lib/src/feature/home/widget/home_screen.dart b/example/lib/src/feature/home/widget/home_screen.dart index 6e0a10a..e177a5d 100644 --- a/example/lib/src/feature/home/widget/home_screen.dart +++ b/example/lib/src/feature/home/widget/home_screen.dart @@ -11,31 +11,27 @@ class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) => Title( - title: 'Octopus', - color: Theme.of(context).colorScheme.primary, - child: Scaffold( - appBar: AppBar( - title: const Text('Home'), - leading: const SizedBox.shrink(), - actions: CommonActions(), - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - ListTile( - title: const Text('Shop'), - subtitle: const Text('Explore nested navigation'), - onTap: () => Octopus.push(context, Routes.shop), - ), - ListTile( - title: const Text('Gallery'), - subtitle: const Text('Gallery description'), - onTap: () => Octopus.push(context, Routes.gallery), - ), - ], - ), + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Home'), + leading: const SizedBox.shrink(), + actions: CommonActions(), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + ListTile( + title: const Text('Shop'), + subtitle: const Text('Explore nested navigation'), + onTap: () => Octopus.push(context, Routes.shop), + ), + ListTile( + title: const Text('Gallery'), + subtitle: const Text('Gallery description'), + onTap: () => Octopus.push(context, Routes.gallery), + ), + ], ), ), ); diff --git a/example/lib/src/feature/shop/widget/category_screen.dart b/example/lib/src/feature/shop/widget/category_screen.dart index 4e4c5c3..3e79d73 100644 --- a/example/lib/src/feature/shop/widget/category_screen.dart +++ b/example/lib/src/feature/shop/widget/category_screen.dart @@ -8,6 +8,7 @@ import 'package:example/src/feature/shop/widget/catalog_breadcrumbs.dart'; import 'package:example/src/feature/shop/widget/shop_back_button.dart'; import 'package:example/src/feature/shop/widget/shop_scope.dart'; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:octopus/octopus.dart'; /// {@template category_screen} @@ -136,8 +137,8 @@ class ProductsSliverGridView extends StatelessWidget { maxCrossAxisExtent: 152, //mainAxisExtent: 180, childAspectRatio: 152 / 180, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + crossAxisSpacing: 4, + mainAxisSpacing: 4, ), itemCount: products.length, itemBuilder: (context, index) { @@ -158,35 +159,30 @@ class _ProductTile extends StatelessWidget { final ProductEntity product; final void Function(BuildContext context, ProductEntity product)? onTap; - Widget discountBanner(Widget child) => product.discountPercentage >= 15 - ? ClipRect( - child: Banner( - location: BannerLocation.topEnd, - message: '${product.discountPercentage.round()}%', - child: child, - ), - ) - : child; + Widget discountBanner({required Widget child}) => + product.discountPercentage >= 15 + ? ClipRect( + child: Banner( + location: BannerLocation.topEnd, + message: '${product.discountPercentage.round()}%', + child: child, + ), + ) + : child; @override - Widget build(BuildContext context) => discountBanner( - Card( - color: const Color(0xFFcfd8dc), - margin: EdgeInsets.zero, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => onTap == null - ? Octopus.push( - context, - Routes.product, - arguments: {'id': product.id.toString()}, - ) - : onTap?.call(context, product), - child: Padding( + Widget build(BuildContext context) => Card( + clipBehavior: Clip.antiAlias, + color: Theme.of(context).cardColor, + margin: const EdgeInsets.all(4), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Stack( + children: [ + // Content + Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: Column( mainAxisSize: MainAxisSize.max, @@ -195,42 +191,166 @@ class _ProductTile extends StatelessWidget { child: Center( child: AspectRatio( aspectRatio: 1, - child: Ink( - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: Ink.image( - image: product.thumbnail.startsWith('assets/') - ? AssetImage(product.thumbnail) - : NetworkImage(product.thumbnail) - as ImageProvider, - fit: BoxFit.cover, - alignment: Alignment.center, - child: const SizedBox.expand(), + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(8), + child: discountBanner( + child: _ProductCardImage(product: product)), + ), ), - ), + Align( + alignment: const Alignment(-.65, .75), + child: _ProductPriceTag(product: product), + ), + ], ), ), ), ), SizedBox( height: 36, - child: Center( - child: Text( - product.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Center( + child: Text( + product.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + height: 0.8, + letterSpacing: -0.3, + fontWeight: FontWeight.w500, + ), + ), ), ), ), ], ), ), + + // Tap area + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => onTap == null + ? Octopus.push( + context, + Routes.product, + arguments: { + 'id': product.id.toString() + }, + ) + : onTap?.call(context, product), + ), + ), + ), + ], + ), + ); +} + +class _ProductCardImage extends StatelessWidget { + const _ProductCardImage({ + required this.product, + super.key, // ignore: unused_element + }); + + final ProductEntity product; + + ImageProvider get _imageProvider => + (product.thumbnail.startsWith('assets/') + ? AssetImage(product.thumbnail) + : NetworkImage(product.thumbnail)) as ImageProvider; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(16), + image: DecorationImage( + image: _imageProvider, + fit: BoxFit.cover, + alignment: Alignment.center, + ), + ), + ); +} + +class _ProductPriceTag extends StatelessWidget { + const _ProductPriceTag({ + required this.product, + super.key, // ignore: unused_element + }); + + final ProductEntity product; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 2), + child: DefaultTextStyle( + maxLines: 1, + overflow: TextOverflow.ellipsis, + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), + style: GoogleFonts.coiny( + height: 1, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 1, + shadows: [ + const BoxShadow( + color: Colors.black, + offset: Offset.zero, + blurRadius: 1, + blurStyle: BlurStyle.solid, + ), + const BoxShadow( + color: Colors.black, + offset: Offset.zero, + blurRadius: 2, + blurStyle: BlurStyle.solid, + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 6), + child: Text( + r'$', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 1), + Text( + product.price.toStringAsFixed(0), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ], + ), ), ), ); diff --git a/example/lib/src/feature/shop/widget/product_screen.dart b/example/lib/src/feature/shop/widget/product_screen.dart index 86859e5..7889742 100644 --- a/example/lib/src/feature/shop/widget/product_screen.dart +++ b/example/lib/src/feature/shop/widget/product_screen.dart @@ -13,6 +13,7 @@ import 'package:example/src/feature/shop/widget/shop_back_button.dart'; import 'package:example/src/feature/shop/widget/shop_scope.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; /// {@template product_screen} /// ProductScreen widget. @@ -233,31 +234,85 @@ class _ProductRatingAndPrice extends StatelessWidget { height: 64, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${product.price} \$', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .headlineLarge - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w800, - height: 1, - ), + child: Center( + child: DefaultTextStyle( + maxLines: 1, + overflow: TextOverflow.ellipsis, + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, ), - const SizedBox(width: 24), - const Icon( - Icons.shopping_cart, + style: GoogleFonts.coiny( + height: 1, + fontWeight: FontWeight.w800, color: Colors.white, - size: 32, + letterSpacing: 1, + shadows: [ + const BoxShadow( + color: Colors.black, + offset: Offset.zero, + blurRadius: 1, + blurStyle: BlurStyle.solid, + ), + const BoxShadow( + color: Colors.black, + offset: Offset.zero, + blurRadius: 2, + blurStyle: BlurStyle.solid, + ), + ], ), - ], + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 6), + child: Text( + r'$', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + product.price.toString(), + style: const TextStyle( + fontSize: 46, + fontWeight: FontWeight.w800, + ), + ), + ), + const Padding( + padding: EdgeInsets.only(left: 16), + child: Icon( + Icons.shopping_cart, + color: Colors.white, + size: 32, + shadows: [ + BoxShadow( + color: Colors.black, + offset: Offset.zero, + blurRadius: 1, + blurStyle: BlurStyle.solid, + ), + BoxShadow( + color: Colors.black, + offset: Offset.zero, + blurRadius: 1.5, + blurStyle: BlurStyle.solid, + ), + ], + ), + ), + ], + ), + ), ), ), ), diff --git a/lib/src/state/state.dart b/lib/src/state/state.dart index 0524d3a..cdae390 100644 --- a/lib/src/state/state.dart +++ b/lib/src/state/state.dart @@ -636,6 +636,10 @@ mixin OctopusRoute { /// e.g. my-page String get name; + // TODO(plugfox): implement title builder for active route + /// Title of this route. + String? get title => null; + /// Build [Widget] for this route using [BuildContext] and [OctopusNode]. /// /// Use [OctopusNode] to access current route information,