summaryrefslogtreecommitdiff
path: root/lib/main.dart
diff options
context:
space:
mode:
Diffstat (limited to 'lib/main.dart')
-rw-r--r--lib/main.dart1265
1 files changed, 1265 insertions, 0 deletions
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..3dbbbdb
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,1265 @@
+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 <int, Color>{
+ 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 <int, Color>{
+ 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<String, dynamic> 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<StoreItem> 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<String, dynamic> json) {
+ return StoreQuery(
+ data:
+ List<StoreItem>.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<StoreQuery> 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<StoreItem> 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('<br>', '\n'),
+ newLineAsBreaks: true,
+ maxLines: maxLines,
+ style: style,
+ textAlign: textAlign,
+ textDirection: textDirection,
+ tags: tags,
+ );
+ } else {
+ return StyledText(
+ text: markup.replaceAll('<br>', '\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<bool>? 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: <Widget>[
+ 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<Widget> 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<StoreItem>.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<StoreItem>.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());
+}