import 'dart:async'; import 'dart:math'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:google_fonts/google_fonts.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:qlevar_router/qlevar_router.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:flutter_rating_bar/flutter_rating_bar.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart'; import 'package:flutter_zoom_drawer/flutter_zoom_drawer.dart'; import 'package:like_button/like_button.dart'; import 'package:styled_text/styled_text.dart'; final int kBlurHashDecodingSize = 18; final double kThumbWidth = 460; final double kThumbHeight = 215; class DSColors { static const MaterialColor light = const MaterialColor( 0xfffcfcfc, const { 50: const Color(0xfffcfcfc), 100: const Color(0xfffcfcfc), 200: const Color(0xfffcfcfc), 300: const Color(0xfffcfcfc), 400: const Color(0xfffcfcfc), 500: const Color(0xfffcfcfc), 600: const Color(0xfffcfcfc), 700: const Color(0xfffcfcfc), 800: const Color(0xfffcfcfc), 900: const Color(0xfffcfcfc), }, ); static const MaterialColor dark = const MaterialColor( 0xff221e1f, const { 50: const Color(0xff221e1f), 100: const Color(0xff221e1f), 200: const Color(0xff221e1f), 300: const Color(0xff221e1f), 400: const Color(0xff221e1f), 500: const Color(0xff221e1f), 600: const Color(0xff221e1f), 700: const Color(0xff221e1f), 800: const Color(0xff221e1f), 900: const Color(0xff221e1f), }, ); } class StoreItem { final int id; final String cover; final String thumb; final String name; final String body; final String category; final String creator; final int price; const StoreItem({ required this.id, required this.cover, required this.thumb, required this.name, required this.body, required this.category, required this.creator, required this.price, }); const StoreItem.empty() : id = 0, cover = '', thumb = '', name = '', body = '', category = '', creator = '', price = 0; factory StoreItem.fromJson(Map json) { return StoreItem( id: json['id'], name: json['title'], body: json['body'], price: json['price'], cover: json['header'], thumb: json['header'], category: 'RPG', creator: 'Unknown', ); } } class StoreQuery { final List data; final int page_num; final int page_size; final int total_elements; const StoreQuery({ required this.data, required this.page_num, required this.page_size, required this.total_elements, }); factory StoreQuery.fromJson(Map json) { return StoreQuery( data: List.from(json['data'].map((p) => StoreItem.fromJson(p))), page_num: json['page_num'], page_size: json['page_size'], total_elements: json['total_elements'], ); } } class Backend { final String url; const Backend(this.url); Future products(String query) async { final uri = Uri.http(this.url, '/products', {"prefix": query}); final response = await http.get(uri); if (response.statusCode == 200) { return StoreQuery.fromJson(jsonDecode(response.body)); } else { throw Exception('Failed'); } } Future product(String id) async { final uri = Uri.http(this.url, '/product/${id}'); final response = await http.get(uri); if (response.statusCode == 200) { return StoreItem.fromJson(jsonDecode(response.body)); } else { throw Exception('Failed'); } } } final API = Backend('127.0.0.1:8000'); class HiButton extends HookWidget { final Widget child; final VoidCallback onTap; final Icon? icon; final double? width; final double? height; final BorderRadius? borderRadius; const HiButton({ Key? key, required this.child, required this.onTap, this.icon, this.width, this.height, this.borderRadius, }) : super(key: key); HiButton.text( String text, { Key? key, required this.onTap, this.icon, this.width, this.height, this.borderRadius, }) : child = Text( text, textAlign: TextAlign.center, style: TextStyle( fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w700, ), ), super(key: key); @override Widget build(BuildContext context) { late final combined; if (icon != null) { combined = Row(mainAxisSize: MainAxisSize.min, children: [ icon!, const SizedBox(width: 8.0), child, ]); } else { combined = child; } return InkWrapper( child: Container( width: width ?? double.infinity, height: height, decoration: BoxDecoration( borderRadius: borderRadius, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.25), offset: Offset(0, 1), blurRadius: 3, spreadRadius: 0, ), ], gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.green.shade400, Colors.green, ], ), ), padding: const EdgeInsets.all(12.0), child: Center(child: combined), ), onTap: onTap, ); } } class Markup extends StatelessWidget { final String markup; final bool selectable; final int? maxLines; final TextOverflow overflow; final TextStyle? style; final TextAlign textAlign; final TextDirection? textDirection; const Markup(this.markup, {Key? key, this.selectable = false, this.maxLines, this.overflow = TextOverflow.fade, this.style, this.textAlign = TextAlign.start, this.textDirection}) : super(key: key); const Markup.selectable(this.markup, {Key? key, this.maxLines, this.style, this.textAlign = TextAlign.start, this.textDirection}) : selectable = true, overflow = TextOverflow.clip, super(key: key); @override Widget build(BuildContext context) { final tags = { 'h1': StyledTextTag(), 'ul': StyledTextTag(), 'li': StyledTextTag(), 'p': StyledTextTag(), 'bold': StyledTextTag(style: TextStyle(fontWeight: FontWeight.bold)), 'img': StyledTextActionTag( (text, attrs) { final String link = attrs['src'] ?? ''; print('The "$link" link is tapped.'); }, style: TextStyle(decoration: TextDecoration.underline), ), 'a': StyledTextActionTag( (text, attrs) { final String link = attrs['href'] ?? ''; print('The "$link" link is tapped.'); }, style: TextStyle(decoration: TextDecoration.underline), ), 'color': StyledTextCustomTag( baseStyle: TextStyle(fontStyle: FontStyle.italic), parse: (baseStyle, attributes) { if (attributes.containsKey('text') && (attributes['text']!.substring(0, 1) == '#') && attributes['text']!.length >= 6) { final String hexColor = attributes['text']!.substring(1); final String alphaChannel = (hexColor.length == 8) ? hexColor.substring(6, 8) : 'FF'; final Color color = Color(int.parse('0x$alphaChannel' + hexColor.substring(0, 6))); return baseStyle?.copyWith(color: color); } else { return baseStyle; } }, ), }; if (selectable) { return StyledText.selectable( text: markup.replaceAll('
', '\n'), newLineAsBreaks: true, maxLines: maxLines, style: style, textAlign: textAlign, textDirection: textDirection, tags: tags, ); } else { return StyledText( text: markup.replaceAll('
', '\n'), newLineAsBreaks: true, maxLines: maxLines, overflow: overflow, style: style, textAlign: textAlign, textDirection: textDirection, tags: tags, ); } } } class HeaderTab extends StatelessWidget { final Widget child; final bool selected; final VoidCallback onTap; const HeaderTab( {Key? key, required this.child, required this.selected, required this.onTap}) : super(key: key); HeaderTab.text(String text, this.selected, this.onTap, {Key? key}) : child = Text(text), super(key: key); @override Widget build(BuildContext context) { late final Widget layout; if (this.selected) { layout = IntrinsicWidth( child: Column(mainAxisSize: MainAxisSize.min, children: [ Flexible(child: child), Container(height: 2.0, color: Colors.blue), ]), ); } else { layout = child; } return InkWrapper( child: Container( padding: const EdgeInsets.all(12.0), child: layout, ), onTap: onTap, ); } } class HeaderNav extends StatelessWidget { const HeaderNav({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final tab = QR.currentPath.split('/')[1]; print('tab: ${QR.currentPath} : $tab'); return Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ HeaderTab.text('STORE', tab == 'store', () => QR.to('/store')), const SizedBox(width: 1.0), HeaderTab.text('LIBRARY', tab == 'library', () => QR.to('/library')), const SizedBox(width: 1.0), HeaderTab( child: Padding( padding: EdgeInsets.all(1.0), child: Icon(Icons.search), ), selected: false, onTap: () => {}, ), ]); } } class ProfilePicture extends HookWidget { final double size; final VoidCallback onTap; const ProfilePicture({Key? key, required this.size, required this.onTap}) : super(key: key); @override Widget build(BuildContext context) { final imageDownloaded = useState(false); final url = 'https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/avatars/ba/baa7f4330ccbb009709c840182c743bb853a826b_full.jpg'; late final child; if (imageDownloaded.value) { child = Image.network(url, fit: BoxFit.fill); } else { child = BlurHash( hash: 'LsG*BvEeM{n#-tR%W9oI5Tw[xCay', image: url, imageFit: BoxFit.fill, decodingWidth: size.toInt(), decodingHeight: size.toInt(), onReady: () => Future.delayed(const Duration(milliseconds: 1), () => imageDownloaded.value = true), decodingMode: DecodingMode.isolate, ); } return Container( padding: const EdgeInsets.all(2.0), color: Colors.green, child: SizedBox.square( dimension: size - 4.0, child: InkWrapper( child: child, onTap: onTap, ), ), ); } } class DoujinSeaScaffold extends StatelessWidget { final Widget child; DoujinSeaScaffold({Key? key, required this.child}) : super(key: key); final _profilePictureKey = GlobalKey(); final _zoomDrawerController = ZoomDrawerController(); final double _height = 48.0; @override Widget build(BuildContext context) { final slideWidth = min(MediaQuery.of(context).size.width * 0.55, 250.0); return WillPopScope( onWillPop: () async => !Navigator.of(context).userGestureInProgress, child: ZoomDrawer( controller: _zoomDrawerController, slideWidth: slideWidth, style: DrawerStyle.Style2, isRtl: true, backgroundColor: Colors.grey.shade300, menuScreen: Theme( data: ThemeData.dark(), child: Material( child: SafeArea( child: Align( alignment: Alignment.topRight, child: SizedBox( width: slideWidth, child: Column(children: [ Padding( padding: EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), child: HiButton.text( 'Online', borderRadius: BorderRadius.all(Radius.circular(4.0)), height: _height - 8.0, onTap: () => {}, ), ), const SizedBox(height: 8.0), ListTile( trailing: Icon(Icons.account_circle), title: Text('Profile'), ), ListTile( trailing: Icon(Icons.people), title: Text('Friends'), ), ListTile( trailing: Icon(Icons.shopping_cart), title: Text('Cart'), ), Expanded(child: SizedBox.shrink()), ListTile( trailing: Icon(Icons.help), title: Text('Help'), ), ListTile( trailing: Icon(Icons.info), title: Text('About'), ), ListTile( trailing: Icon(Icons.settings), title: Text('Settings'), ), const SizedBox(height: 24.0), Row(children: [ Expanded(child: SizedBox.shrink()), Text('Terms of Service', style: TextStyle(fontSize: 10.0)), const SizedBox(width: 8.0), Text('|', style: TextStyle(fontSize: 10.0)), const SizedBox(width: 8.0), Text('Privacy Policy', style: TextStyle(fontSize: 10.0)), Expanded(child: SizedBox.shrink()), ]), const SizedBox(height: 24.0), ]), ), ), ), ), ), mainScreen: Scaffold( appBar: PreferredSize( preferredSize: Size.fromHeight(_height), child: AppBar( primary: true, centerTitle: false, automaticallyImplyLeading: false, titleSpacing: 0.0, title: Container( height: _height, child: HeaderNav(), ), actions: [ ProfilePicture( key: _profilePictureKey, size: _height, onTap: () { _zoomDrawerController.toggle?.call(); }, ), ], ), ), body: child, ), ), ); } } class InkWrapper extends StatelessWidget { final Color? splashColor; final Widget child; final VoidCallback onTap; final ValueChanged? onHover; const InkWrapper({ Key? key, this.splashColor, required this.child, required this.onTap, this.onHover, }) : super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ child, Positioned.fill( child: Material( color: Colors.transparent, child: InkWell( splashColor: splashColor, onTap: onTap, onHover: onHover, ), ), ), ], ); } } final colors = const [ Colors.red, Colors.green, Colors.greenAccent, Colors.amberAccent, Colors.blue, Colors.amber, ]; void useInterval(VoidCallback callback, Duration delay, bool enabled) { final savedCallback = useRef(callback); final savedEnabled = useRef(enabled); savedCallback.value = callback; savedEnabled.value = enabled; useEffect(() { if (savedEnabled.value) { final timer = Timer.periodic(delay, (_) => savedCallback.value()); return timer.cancel; } return () => {}; }, [delay]); } class Carousel extends HookWidget { final int itemCount; final int itemsPerPage; final IndexedWidgetBuilder itemBuilder; final double height; final bool showIndicator; final bool infinite; final Color? color; Carousel({ Key? key, required this.itemCount, required this.itemBuilder, required this.height, this.itemsPerPage = 4, this.showIndicator = true, this.infinite = true, this.color, }) : super(key: key); final _flipDuration = Duration(seconds: 4); final _transDuration = Duration(seconds: 1); final _transCurve = Curves.easeInOutCirc; @override Widget build(BuildContext context) { final calculatedItemsPerPage = min(itemCount, itemsPerPage); final pageCount = (itemCount / calculatedItemsPerPage).ceil(); final shouldShowIndicator = showIndicator && pageCount > 1; final controller = usePageController(keepPage: true, initialPage: pageCount * 100 + 1); final userInteracted = useState(pageCount == 1); final animating = useState(false); final isMounted = useIsMounted(); useInterval(() { if (!isMounted() || userInteracted.value) return; animating.value = true; controller .nextPage(duration: _transDuration, curve: _transCurve) .then((_) { if (!isMounted()) return; animating.value = false; }); }, _flipDuration, !userInteracted.value); final pageView = Container( color: color, height: height, child: PageView.builder( scrollDirection: Axis.horizontal, physics: (pageCount == 1 ? NeverScrollableScrollPhysics() : null), onPageChanged: (_) { if (!isMounted() || animating.value) return; userInteracted.value = true; }, controller: controller, itemCount: (infinite ? null : pageCount), itemBuilder: (_, index) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final width = constraints.maxWidth / calculatedItemsPerPage; List items = []; final first = index * calculatedItemsPerPage; for (int i = 0; i < calculatedItemsPerPage; ++i) { items.add(SizedBox( width: width, height: height, child: itemBuilder(_, (first + i) % itemCount))); } return Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: items, ); }); }, ), ); if (shouldShowIndicator) { return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ pageView, SizedBox(height: 8.0), SmoothPageIndicator( controller: controller, count: pageCount, effect: SlideEffect(dotWidth: 6.0, dotHeight: 6.0), onDotClicked: (index) { if (animating.value) return; userInteracted.value = true; final page = controller.page?.toInt() ?? 0; final from = page % pageCount; final to = page + (index - from); controller.animateToPage(to, duration: _transDuration, curve: _transCurve); }, ), ], ); } return pageView; } } class StaticFooterScrollView extends StatelessWidget { final Widget child; final Widget footer; const StaticFooterScrollView( {Key? key, required this.child, required this.footer}) : super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ SingleChildScrollView( child: child, ), Column( mainAxisAlignment: MainAxisAlignment.end, children: [ footer, ], ) ], ); } } class StoreItemPage extends HookWidget { const StoreItemPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final item = useState(StoreItem.empty()); useEffect(() { API.product(QR.params['itemId'].toString()).then((v) => item.value = v); }, []); if (item.value.id == 0) { return SizedBox.shrink(); } final mobile = MediaQuery.of(context).size.width <= 640; final itemsPerPage = max(MediaQuery.of(context).size.width ~/ 356, 1); late final header, header2, footer; if (mobile) { header = Carousel( height: kThumbHeight, color: Colors.black, itemCount: 4, itemsPerPage: 1, itemBuilder: (_, index) => BlurHash( hash: 'LsG*BvEeM{n#-tR%W9oI5Tw[xCay', image: item.value.cover, imageFit: BoxFit.contain, decodingWidth: kBlurHashDecodingSize, decodingHeight: kBlurHashDecodingSize, decodingMode: DecodingMode.isolate, ), ); header2 = SizedBox.shrink(); footer = HiButton.text( 'Add To Cart', icon: Icon(Icons.shopping_cart, color: Colors.white), height: 48.0, onTap: () => {}, ); } else { header = Stack( children: [ SizedBox( height: max(MediaQuery.of(context).size.width * 0.28, kThumbHeight), child: BlurHash( hash: 'LsG*BvEeM{n#-tR%W9oI5Tw[xCay', imageFit: BoxFit.cover, decodingWidth: kBlurHashDecodingSize, decodingHeight: kBlurHashDecodingSize, decodingMode: DecodingMode.isolate, ), ), Positioned.fill( child: Center( child: SizedBox( width: 560, height: 420, child: BlurHash( hash: 'LsG*BvEeM{n#-tR%W9oI5Tw[xCay', image: item.value.cover, imageFit: BoxFit.contain, decodingWidth: kBlurHashDecodingSize, decodingHeight: kBlurHashDecodingSize, decodingMode: DecodingMode.isolate, ), ), ), ), ], ); header2 = Carousel( height: 152.0, itemCount: 4, itemsPerPage: itemsPerPage, itemBuilder: (_, index) => BlurHash( hash: 'LsG*BvEeM{n#-tR%W9oI5Tw[xCay', image: item.value.cover, imageFit: BoxFit.contain, decodingWidth: kBlurHashDecodingSize, decodingHeight: kBlurHashDecodingSize, decodingMode: DecodingMode.isolate, ), ); footer = Align( alignment: Alignment.bottomRight, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: Theme.of(context).scaffoldBackgroundColor, ), padding: EdgeInsets.all(12.0), child: IntrinsicWidth( child: Column( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end, children: [ SelectableText( '12,000円', style: TextStyle( fontSize: 24.0 / 2, decoration: TextDecoration.lineThrough, height: 1.2, ), ), const SizedBox(height: 4.0), Row(mainAxisAlignment: MainAxisAlignment.end, children: [ Badge.text('SALE -50%', margin: EdgeInsets.only(top: 4.0)), const SizedBox(width: 8.0), SelectableText( item.value.price.toString(), style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.w700, height: 1.2, ), ), ]), const SizedBox(height: 4.0), HiButton.text( 'Add To Cart', icon: Icon(Icons.shopping_cart, color: Colors.white), height: 48.0, onTap: () => {}, ) ], ), ), ), ); } return DoujinSeaScaffold( child: StaticFooterScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ header, Padding( padding: EdgeInsets.symmetric( horizontal: (mobile ? 8.0 : MediaQuery.of(context).size.width * 0.18), vertical: 8.0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: SelectableText( item.value.name, style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.w700, ), ), ), LikeButton(), ]), const SizedBox(height: 8.0), Row(children: [ RatingBarIndicator( rating: 3, itemCount: 5, itemSize: 12.0, itemBuilder: (context, _) => Icon( Icons.star, color: Colors.amber, ), ), const SizedBox(width: 8.0), Container(color: Colors.black, width: 1, height: 12.0), const SizedBox(width: 8.0), const FaIcon(FontAwesomeIcons.windows, size: 12.0), const SizedBox(width: 8.0), const FaIcon(FontAwesomeIcons.apple, size: 12.0), const SizedBox(width: 8.0), const FaIcon(FontAwesomeIcons.linux, size: 12.0), const SizedBox(width: 8.0), const FaIcon(FontAwesomeIcons.android, size: 12.0), const Expanded(child: const SizedBox.shrink()), SelectableText( '12,000円', style: TextStyle( fontSize: 24.0 / 2, decoration: TextDecoration.lineThrough, height: 1.2, ), ), ]), const SizedBox(height: 4.0), Row(mainAxisAlignment: MainAxisAlignment.start, children: [ Badge.text('RPG', margin: EdgeInsets.only(top: 4.0)), const Expanded(child: const SizedBox.shrink()), Badge.text('SALE -50%', margin: EdgeInsets.only(top: 4.0)), const SizedBox(width: 8.0), SelectableText( item.value.price.toString(), style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.w700, height: 1.2, ), ), ]), const SizedBox(height: 20.0), header2, const SizedBox(height: 20.0), Markup.selectable(item.value.body), const SizedBox(height: 20.0), Container( color: Colors.black, width: double.infinity, height: 1.0), const SizedBox(height: 12.0), Row(mainAxisAlignment: MainAxisAlignment.start, children: [ SelectableText( 'Reviews', style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.w700, height: 1.2, ), ), const SizedBox(width: 8.0), RatingBarIndicator( rating: 3, itemCount: 5, itemSize: 18.0, itemBuilder: (context, _) => Icon( Icons.star, color: Colors.amber, ), ), ]), const SizedBox(height: 8.0), Carousel( height: kThumbHeight, showIndicator: false, itemCount: 24, itemsPerPage: itemsPerPage, itemBuilder: (_, index) => Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: Theme.of(context).canvasColor, ), margin: EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: EdgeInsets.all(18.0), child: Container( height: 280, child: Center(child: Markup(item.value.body)), ), ), ), const SizedBox(height: 20.0), Container( color: Colors.black, width: double.infinity, height: 1.0), const SizedBox(height: 12.0), SelectableText( 'System Requirements', style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.w700, height: 1.2, ), ), const SizedBox(height: 20.0), Container( color: Colors.black, width: double.infinity, height: 1.0), const SizedBox(height: 12.0), SelectableText( 'Developer', style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.w700, height: 1.2, ), ), const SizedBox(height: 20.0), Container( color: Colors.black, width: double.infinity, height: 1.0), const SizedBox(height: 12.0), SelectableText( 'You may also like', style: TextStyle( fontSize: 24.0, fontWeight: FontWeight.w700, height: 1.2, ), ), const SizedBox(height: 8.0), /* Carousel( height: kThumbHeight, itemCount: 8, itemsPerPage: itemsPerPage, itemBuilder: (_, index) => StorePanel( item: kStoreItems[index % kStoreItems.length], alwaysShowDetails: true, ), ), */ const SizedBox(height: 66.0), ], ), ) ], ), footer: footer, ), ); } } class Badge extends StatelessWidget { final Widget child; final EdgeInsets? margin; const Badge({Key? key, required this.child, this.margin}) : super(key: key); Badge.text(String text, {Key? key, this.margin}) : child = Text( text, style: TextStyle(color: Colors.white, fontSize: 10.0), ), super(key: key); @override Widget build(BuildContext context) { return Container( margin: margin, padding: EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black.withOpacity(0.8), borderRadius: BorderRadius.all(Radius.circular(8)), ), child: child, ); } } class StoreThumb extends StatelessWidget { final StoreItem item; const StoreThumb({Key? key, required this.item}) : super(key: key); @override Widget build(BuildContext context) { return Image.network( item.thumb, width: double.infinity, height: double.infinity, fit: BoxFit.contain, ); return BlurHash( hash: 'LsG*BvEeM{n#-tR%W9oI5Tw[xCay', image: item.thumb, decodingWidth: kBlurHashDecodingSize, decodingHeight: kBlurHashDecodingSize, decodingMode: DecodingMode.isolate, ); } } class StorePanel extends HookWidget { final StoreItem item; final bool alwaysShowDetails; const StorePanel( {Key? key, required this.item, this.alwaysShowDetails = false}) : super(key: key); @override Widget build(BuildContext context) { final hover = useState(false); if (!hover.value && !alwaysShowDetails) { return InkWrapper( child: StoreThumb(item: item), onTap: () => QR.to('/store/item/${item.id}'), onHover: (v) => hover.value = v, ); } return InkWrapper( child: Stack(children: [ StoreThumb(item: item), Container( padding: EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Badge.text('RPG'), Badge( child: RatingBarIndicator( rating: 3, itemCount: 5, itemSize: 12.0, itemBuilder: (context, _) => Icon( Icons.star, color: Colors.amber, ), ), ), Badge.text(item.price.toString()), ]), Badge.text(item.name), ])), ]), onTap: () => QR.to('/store/item/${item.id}'), onHover: (alwaysShowDetails ? (_) => {} : (v) => hover.value = v), ); } } class StorePage extends HookWidget { const StorePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final items = useState(List.empty()); useEffect(() { API.products('Fin').then((v) => items.value = v.data); }, []); return DoujinSeaScaffold( child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return GridView.builder( gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: constraints.maxWidth ~/ kThumbWidth, childAspectRatio: kThumbWidth / kThumbHeight, ), itemCount: items.value.length, itemBuilder: (_, index) => StorePanel(item: items.value[index]), ); }, ), ); } } class LibraryPage extends HookWidget { const LibraryPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final items = useState(List.empty()); useEffect(() { API.products('Dark Souls').then((v) => items.value = v.data); }, []); return DoujinSeaScaffold( child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return GridView.builder( gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: constraints.maxWidth ~/ kThumbWidth, childAspectRatio: kThumbWidth / kThumbHeight, ), itemCount: items.value.length, itemBuilder: (_, index) => StorePanel(item: items.value[index]), ); }, ), ); } } class MainPage extends StatelessWidget { const MainPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { QR.navigator.replaceAll('/store'); return SizedBox.shrink(); } } class AppRoutes { final routes = [ QRoute(path: '/', builder: () => const MainPage()), QRoute( path: '/store', builder: () => const StorePage(), children: [ QRoute( path: '/item/:itemId', builder: () => const StoreItemPage(), ), ], ), QRoute( path: '/library', builder: () => const LibraryPage(), children: [ QRoute( path: '/item/:itemId', builder: () => DoujinSeaScaffold(child: SizedBox.shrink()), ), ], ), ]; } class DoujinSeaApp extends StatelessWidget { const DoujinSeaApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp.router( title: 'DoujinSea', debugShowCheckedModeBanner: false, theme: ThemeData( appBarTheme: AppBarTheme(color: DSColors.dark), brightness: Brightness.light, colorScheme: ColorScheme.light(), textTheme: GoogleFonts.latoTextTheme( Typography.blackHelsinki, ), ), darkTheme: ThemeData( appBarTheme: AppBarTheme(color: DSColors.dark), brightness: Brightness.dark, colorScheme: ColorScheme.dark(), textTheme: GoogleFonts.latoTextTheme( Typography.whiteHelsinki, ), ), themeMode: ThemeMode.light, routeInformationParser: QRouteInformationParser(), routerDelegate: QRouterDelegate(AppRoutes().routes, withWebBar: false), ); } } void main() { QR.settings.enableDebugLog = true; QR.setUrlStrategy(); QR.settings.pagesType = QFadePage(); runApp(const DoujinSeaApp()); }