import 'package:flutter/material.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/history_image_provider.dart'; import 'package:venera/foundation/image_provider/local_comic_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/image_favorites_page/image_favorites_page.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/import_comic.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; import 'local_comics_page.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { var widget = SmoothCustomScrollView( slivers: [ SliverPadding(padding: EdgeInsets.only(top: context.padding.top)), const _SearchBar(), const _SyncDataWidget(), const _History(), const _Local(), const _ComicSourceWidget(), const _AccountsWidget(), const ImageFavorites(), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), ], ); return context.width > changePoint ? widget.paddingHorizontal(8) : widget; } } class _SearchBar extends StatelessWidget { const _SearchBar(); @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Container( height: 52, width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Material( color: context.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(32), child: InkWell( borderRadius: BorderRadius.circular(32), onTap: () { context.to(() => const SearchPage()); }, child: Row( children: [ const SizedBox(width: 16), const Icon(Icons.search), const SizedBox(width: 8), Text('Search'.tl, style: ts.s16), const Spacer(), ], ), ), ), ), ); } } class _SyncDataWidget extends StatefulWidget { const _SyncDataWidget(); @override State<_SyncDataWidget> createState() => _SyncDataWidgetState(); } class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver { @override void initState() { super.initState(); DataSync().addListener(update); WidgetsBinding.instance.addObserver(this); lastCheck = DateTime.now(); } void update() { if (mounted) { setState(() {}); } } @override void dispose() { super.dispose(); DataSync().removeListener(update); WidgetsBinding.instance.removeObserver(this); } late DateTime lastCheck; @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { if (DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) { lastCheck = DateTime.now(); DataSync().downloadData(); } } } @override Widget build(BuildContext context) { Widget child; if (!DataSync().isEnabled) { child = const SliverPadding(padding: EdgeInsets.zero); } else if (DataSync().isUploading || DataSync().isDownloading) { child = SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.primary, ), borderRadius: BorderRadius.circular(8), ), child: ListTile( leading: const Icon(Icons.sync), title: Text('Syncing Data'.tl), trailing: const CircularProgressIndicator(strokeWidth: 2) .fixWidth(18) .fixHeight(18), ), ), ); } else { child = SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, ), borderRadius: BorderRadius.circular(8), ), child: ListTile( leading: const Icon(Icons.sync), title: Text('Sync Data'.tl), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.cloud_upload_outlined), onPressed: () async { DataSync().uploadData(); }), IconButton( icon: const Icon(Icons.cloud_download_outlined), onPressed: () async { DataSync().downloadData(); }), ], ), ), ), ); } return SliverAnimatedPaintExtent( duration: const Duration(milliseconds: 200), child: child, ); } } class _History extends StatefulWidget { const _History(); @override State<_History> createState() => _HistoryState(); } class _HistoryState extends State<_History> { late List history; late int count; void onHistoryChange() { setState(() { history = HistoryManager().getRecent(); count = HistoryManager().count(); }); } @override void initState() { history = HistoryManager().getRecent(); count = HistoryManager().count(); HistoryManager().addListener(onHistoryChange); super.initState(); } @override void dispose() { HistoryManager().removeListener(onHistoryChange); super.dispose(); } @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 0.6, ), borderRadius: BorderRadius.circular(8), ), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { context.to(() => const HistoryPage()); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 56, child: Row( children: [ Center( child: Text('History'.tl, style: ts.s18), ), Container( margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text(count.toString(), style: ts.s12), ), const Spacer(), const Icon(Icons.arrow_right), ], ), ).paddingHorizontal(16), if (history.isNotEmpty) SizedBox( height: 128, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: history.length, itemBuilder: (context, index) { return AnimatedTapRegion( borderRadius: 8, onTap: () { context.to( () => ComicPage( id: history[index].id, sourceKey: history[index].type.sourceKey, ), ); }, child: Container( width: 92, height: 114, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Theme.of(context) .colorScheme .secondaryContainer, ), clipBehavior: Clip.antiAlias, child: AnimatedImage( image: HistoryImageProvider(history[index]), width: 96, height: 128, fit: BoxFit.cover, filterQuality: FilterQuality.medium, ), ), ).paddingHorizontal(8); }, ), ).paddingHorizontal(8).paddingBottom(16), ], ), ), ), ); } } class _Local extends StatefulWidget { const _Local(); @override State<_Local> createState() => _LocalState(); } class _LocalState extends State<_Local> { late List local; late int count; void onLocalComicsChange() { setState(() { local = LocalManager().getRecent(); count = LocalManager().count; }); } @override void initState() { local = LocalManager().getRecent(); count = LocalManager().count; LocalManager().addListener(onLocalComicsChange); super.initState(); } @override void dispose() { LocalManager().removeListener(onLocalComicsChange); super.dispose(); } @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 0.6, ), borderRadius: BorderRadius.circular(8), ), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { context.to(() => const LocalComicsPage()); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 56, child: Row( children: [ Center( child: Text('Local'.tl, style: ts.s18), ), Container( margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text(count.toString(), style: ts.s12), ), const Spacer(), const Icon(Icons.arrow_right), ], ), ).paddingHorizontal(16), if (local.isNotEmpty) SizedBox( height: 128, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: local.length, itemBuilder: (context, index) { return AnimatedTapRegion( onTap: () { local[index].read(); }, borderRadius: 8, child: Container( width: 92, height: 114, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Theme.of(context) .colorScheme .secondaryContainer, ), clipBehavior: Clip.antiAlias, child: AnimatedImage( image: LocalComicImageProvider( local[index], ), width: 96, height: 128, fit: BoxFit.cover, filterQuality: FilterQuality.medium, ), ), ).paddingHorizontal(8); }, ), ).paddingHorizontal(8), Row( children: [ if (LocalManager().downloadingTasks.isNotEmpty) Button.outlined( child: Row( children: [ if (LocalManager().downloadingTasks.first.isPaused) const Icon(Icons.pause_circle_outline, size: 18) else const _AnimatedDownloadingIcon(), const SizedBox(width: 8), Text("@a Tasks".tlParams({ 'a': LocalManager().downloadingTasks.length, })), ], ), onPressed: () { showPopUpWidget(context, const DownloadingPage()); }, ), const Spacer(), Button.filled( onPressed: import, child: Text("Import".tl), ), ], ).paddingHorizontal(16).paddingVertical(8), ], ), ), ), ); } void import() { showDialog( barrierDismissible: false, context: App.rootContext, builder: (context) { return const _ImportComicsWidget(); }, ); } } class _ImportComicsWidget extends StatefulWidget { const _ImportComicsWidget(); @override State<_ImportComicsWidget> createState() => _ImportComicsWidgetState(); } class _ImportComicsWidgetState extends State<_ImportComicsWidget> { int type = 0; bool loading = false; var key = GlobalKey(); var height = 200.0; var folders = LocalFavoritesManager().folderNames; String? selectedFolder; bool copyToLocalFolder = true; bool cancelled = false; @override void dispose() { loading = false; super.dispose(); } @override Widget build(BuildContext context) { String info = [ "Select a directory which contains the comic files.".tl, "Select a directory which contains the comic directories.".tl, "Select a cbz/zip file.".tl, "Select a directory which contains multiple cbz/zip files.".tl, "Select an EhViewer database and a download folder.".tl ][type]; List importMethods = [ "Single Comic".tl, "Multiple Comics".tl, "A cbz file".tl, "Multiple cbz files".tl, "EhViewer downloads".tl ]; return ContentDialog( dismissible: !loading, title: "Import Comics".tl, content: loading ? SizedBox( width: 600, height: height, child: const Center( child: CircularProgressIndicator(), ), ) : Column( key: key, crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(width: 600), ...List.generate(importMethods.length, (index) { return RadioListTile( title: Text(importMethods[index]), value: index, groupValue: type, onChanged: (value) { setState(() { type = value as int; }); }, ); }), if (type != 3) ListTile( title: Text("Add to favorites".tl), trailing: Select( current: selectedFolder, values: folders, minWidth: 112, onTap: (v) { setState(() { selectedFolder = folders[v]; }); }, ), ).paddingHorizontal(8), if (!App.isIOS && !App.isMacOS) CheckboxListTile( enabled: true, title: Text("Copy to app local path".tl), value: copyToLocalFolder, onChanged: (v) { setState(() { copyToLocalFolder = !copyToLocalFolder; }); }).paddingHorizontal(8), const SizedBox(height: 8), Text(info).paddingHorizontal(24), ], ), actions: [ Button.text( child: Row( children: [ Icon( Icons.help_outline, size: 18, color: context.colorScheme.primary, ), const SizedBox(width: 8), Text("help".tl), ], ), onPressed: () { showDialog( context: context, barrierColor: Colors.black.toOpacity(0.2), builder: (context) { var help = ''; help += '${"A directory is considered as a comic only if it matches one of the following conditions:".tl}\n'; help += '${'1. The directory only contains image files.'.tl}\n'; help += '${'2. The directory contains directories which contain image files. Each directory is considered as a chapter.'.tl}\n\n'; help += '${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n'; help += "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" .tl; help += "If you import an EhViewer's database, program will automatically create folders according to the download label in that database." .tl; return ContentDialog( title: "Help".tl, content: Text(help).paddingHorizontal(16), actions: [ Button.filled( child: Text("OK".tl), onPressed: () { context.pop(); }, ), ], ); }, ); }, ).fixWidth(90).paddingRight(8), Button.filled( isLoading: loading, onPressed: selectAndImport, child: Text("Select".tl), ) ], ); } void selectAndImport() async { height = key.currentContext!.size!.height; setState(() { loading = true; }); var importer = ImportComic( selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder); var result = switch (type) { 0 => await importer.directory(true), 1 => await importer.directory(false), 2 => await importer.cbz(), 3 => await importer.multipleCbz(), 4 => await importer.ehViewer(), int() => true, }; if (result) { context.pop(); } else { setState(() { loading = false; }); } } } class _ComicSourceWidget extends StatefulWidget { const _ComicSourceWidget(); @override State<_ComicSourceWidget> createState() => _ComicSourceWidgetState(); } class _ComicSourceWidgetState extends State<_ComicSourceWidget> { late List comicSources; void onComicSourceChange() { setState(() { comicSources = ComicSource.all().map((e) => e.name).toList(); }); } @override void initState() { comicSources = ComicSource.all().map((e) => e.name).toList(); ComicSource.addListener(onComicSourceChange); super.initState(); } @override void dispose() { ComicSource.removeListener(onComicSourceChange); super.dispose(); } @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 0.6, ), borderRadius: BorderRadius.circular(8), ), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { context.to(() => const ComicSourcePage()); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 56, child: Row( children: [ Center( child: Text('Comic Source'.tl, style: ts.s18), ), Container( margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text(comicSources.length.toString(), style: ts.s12), ), const Spacer(), const Icon(Icons.arrow_right), ], ), ).paddingHorizontal(16), if (comicSources.isNotEmpty) SizedBox( width: double.infinity, child: Wrap( runSpacing: 8, spacing: 8, children: comicSources.map((e) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text(e), ); }).toList(), ).paddingHorizontal(16).paddingBottom(16), ), ], ), ), ), ); } } class _AccountsWidget extends StatefulWidget { const _AccountsWidget(); @override State<_AccountsWidget> createState() => _AccountsWidgetState(); } class _AccountsWidgetState extends State<_AccountsWidget> { late List accounts; void onComicSourceChange() { setState(() { accounts.clear(); for (var c in ComicSource.all()) { if (c.isLogged) { accounts.add(c.name); } } }); } @override void initState() { accounts = []; for (var c in ComicSource.all()) { if (c.isLogged) { accounts.add(c.name); } } ComicSource.addListener(onComicSourceChange); super.initState(); } @override void dispose() { ComicSource.removeListener(onComicSourceChange); super.dispose(); } @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 0.6, ), borderRadius: BorderRadius.circular(8), ), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { context.to(() => const AccountsPage()); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 56, child: Row( children: [ Center( child: Text('Accounts'.tl, style: ts.s18), ), Container( margin: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text(accounts.length.toString(), style: ts.s12), ), const Spacer(), const Icon(Icons.arrow_right), ], ), ).paddingHorizontal(16), SizedBox( width: double.infinity, child: Wrap( runSpacing: 8, spacing: 8, children: accounts.map((e) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text(e), ); }).toList(), ).paddingHorizontal(16).paddingBottom(16), ), ], ), ), ), ); } } class _AnimatedDownloadingIcon extends StatefulWidget { const _AnimatedDownloadingIcon(); @override State<_AnimatedDownloadingIcon> createState() => __AnimatedDownloadingIconState(); } class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( lowerBound: -1, vsync: this, duration: const Duration(milliseconds: 2000), )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Container( width: 18, height: 18, decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Theme.of(context).colorScheme.primary, width: 2, ), ), ), clipBehavior: Clip.hardEdge, child: Transform.translate( offset: Offset(0, 18 * _controller.value), child: Icon( Icons.arrow_downward, size: 16, color: Theme.of(context).colorScheme.primary, ), ), ); }, ); } } class ImageFavorites extends StatefulWidget { const ImageFavorites({super.key}); @override State createState() => _ImageFavoritesState(); } class _ImageFavoritesState extends State { ImageFavoritesComputed? imageFavoritesCompute; int displayType = 0; void refreshImageFavorites() async { try { imageFavoritesCompute = await ImageFavoriteManager.computeImageFavorites(); if (mounted) { setState(() {}); } } catch (e, stackTrace) { Log.error("Unhandled Exception", e.toString(), stackTrace); } } @override void initState() { refreshImageFavorites(); ImageFavoriteManager().addListener(refreshImageFavorites); super.initState(); } @override void dispose() { ImageFavoriteManager().removeListener(refreshImageFavorites); super.dispose(); } @override Widget build(BuildContext context) { bool hasData = imageFavoritesCompute != null && !imageFavoritesCompute!.isEmpty; return SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 0.6, ), borderRadius: BorderRadius.circular(8), ), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { context.to(() => const ImageFavoritesPage()); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 56, child: Row( children: [ Center( child: Text('Image Favorites'.tl, style: ts.s18), ), const Spacer(), const Icon(Icons.arrow_right), ], ), ).paddingHorizontal(16), if (hasData) Row( children: [ const Spacer(), buildTypeButton(0, "Tags".tl), const Spacer(), buildTypeButton(1, "Authors".tl), const Spacer(), buildTypeButton(2, "Comics".tl), const Spacer(), ], ), if (hasData) const SizedBox(height: 8), if (hasData) buildChart(switch (displayType) { 0 => imageFavoritesCompute!.tags, 1 => imageFavoritesCompute!.authors, 2 => imageFavoritesCompute!.comics, _ => [], }) .paddingHorizontal(16) .paddingBottom(16), ], ), ), ), ); } Widget buildTypeButton(int type, String text) { const radius = 24.0; return InkWell( borderRadius: BorderRadius.circular(radius), onTap: () async { setState(() { displayType = type; }); await Future.delayed(const Duration(milliseconds: 20)); var scrollController = ScrollControllerProvider.of(context); scrollController.animateTo( scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.ease, ); }, child: AnimatedContainer( width: 96, padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( color: displayType == type ? context.colorScheme.primaryContainer : null, border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 0.6, ), borderRadius: BorderRadius.circular(radius), ), duration: const Duration(milliseconds: 200), child: Center( child: Text( text, style: ts.s16, ), ), ), ); } Widget buildChart(List data) { if (data.isEmpty) { return const SizedBox(); } var maxCount = data.map((e) => e.count).reduce((a, b) => a > b ? a : b); return ConstrainedBox( constraints: BoxConstraints( maxHeight: 164, ), child: SingleChildScrollView( child: Column( key: ValueKey(displayType), children: data.map((e) { return _ChartLine( text: e.text, count: e.count, maxCount: maxCount, enableTranslation: displayType != 2, onTap: (text) { context.to(() => ImageFavoritesPage(initialKeyword: text)); }, ); }).toList(), ), ), ); } } class _ChartLine extends StatefulWidget { const _ChartLine({ required this.text, required this.count, required this.maxCount, required this.enableTranslation, this.onTap, }); final String text; final int count; final int maxCount; final bool enableTranslation; final void Function(String text)? onTap; @override State<_ChartLine> createState() => __ChartLineState(); } class __ChartLineState extends State<_ChartLine> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), value: 0, )..forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { var text = widget.text; var enableTranslation = App.locale.countryCode == 'CN' && widget.enableTranslation; if (enableTranslation) { text = text.translateTagsToCN; } if (widget.enableTranslation && text.contains(':')) { text = text.split(':').last; } return Row( children: [ InkWell( borderRadius: BorderRadius.circular(4), onTap: () { widget.onTap?.call(widget.text); }, child: Text( text, maxLines: 1, overflow: TextOverflow.ellipsis, ) .paddingHorizontal(4) .toAlign(Alignment.centerLeft) .fixWidth(context.width > 600 ? 120 : 80) .fixHeight(double.infinity), ), const SizedBox(width: 8), Expanded( child: LayoutBuilder(builder: (context, constrains) { var width = constrains.maxWidth * widget.count / widget.maxCount; return AnimatedBuilder( animation: _controller, builder: (context, child) { return Container( width: width * _controller.value, height: 18, decoration: BoxDecoration( borderRadius: BorderRadius.circular(2), gradient: LinearGradient( colors: context.isDarkMode ? [ Colors.blue.shade800, Colors.blue.shade500, ] : [ Colors.blue.shade300, Colors.blue.shade600, ], ), ), ).toAlign(Alignment.centerLeft); }, ); }), ), const SizedBox(width: 8), Text( widget.count.toString(), style: ts.s12, ).fixWidth(context.width > 600 ? 60 : 30), ], ).fixHeight(28); } }