mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-07 11:39:29 +00:00
Refactor onboarding to single page
bottom navigation bar
This commit is contained in:
parent
d9345cad2b
commit
5e152a0baf
10 changed files with 285 additions and 101 deletions
|
|
@ -2,9 +2,10 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:whispering_pages/api/authenticated_user_provider.dart';
|
import 'package:whispering_pages/api/authenticated_user_provider.dart';
|
||||||
import 'package:whispering_pages/settings/models/audiobookshelf_server.dart' as model;
|
|
||||||
import 'package:whispering_pages/settings/api_settings_provider.dart';
|
|
||||||
import 'package:whispering_pages/db/storage.dart';
|
import 'package:whispering_pages/db/storage.dart';
|
||||||
|
import 'package:whispering_pages/settings/api_settings_provider.dart';
|
||||||
|
import 'package:whispering_pages/settings/models/audiobookshelf_server.dart'
|
||||||
|
as model;
|
||||||
|
|
||||||
part 'server_provider.g.dart';
|
part 'server_provider.g.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,7 @@ void main() async {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// final _router = MyAppRouter(needOnboarding: _needAuth());
|
var routerConfig = const MyAppRouter().config;
|
||||||
|
|
||||||
// bool _needAuth() {
|
|
||||||
// final apiSettings = ApiSettings().readFromBoxOrCreate();
|
|
||||||
// final servers = AudiobookShelfServer().readFromBoxOrCreate();
|
|
||||||
// return apiSettings.activeUser == null || servers.isEmpty;
|
|
||||||
// }
|
|
||||||
|
|
||||||
class MyApp extends ConsumerWidget {
|
class MyApp extends ConsumerWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
@ -36,16 +30,12 @@ class MyApp extends ConsumerWidget {
|
||||||
final servers = ref.watch(audiobookShelfServerProvider);
|
final servers = ref.watch(audiobookShelfServerProvider);
|
||||||
final apiSettings = ref.watch(apiSettingsProvider);
|
final apiSettings = ref.watch(apiSettingsProvider);
|
||||||
|
|
||||||
bool needOnboarding() {
|
final needOnboarding = apiSettings.activeUser == null || servers.isEmpty;
|
||||||
return apiSettings.activeUser == null || servers.isEmpty;
|
|
||||||
|
if (needOnboarding) {
|
||||||
|
routerConfig.goNamed(Routes.onboarding.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
var routerConfig = MyAppRouter(needOnboarding: needOnboarding()).config;
|
|
||||||
// if (needOnboarding()) {
|
|
||||||
// routerConfig.goNamed(Routes.onboarding);
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
theme: lightTheme,
|
theme: lightTheme,
|
||||||
darkTheme: darkTheme,
|
darkTheme: darkTheme,
|
||||||
|
|
@ -53,7 +43,6 @@ class MyApp extends ConsumerWidget {
|
||||||
? ThemeMode.dark
|
? ThemeMode.dark
|
||||||
: ThemeMode.light,
|
: ThemeMode.light,
|
||||||
routerConfig: routerConfig,
|
routerConfig: routerConfig,
|
||||||
// routerConfig: _router.config,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ class OnboardingSinglePage extends HookConsumerWidget {
|
||||||
final usernameController = useTextEditingController();
|
final usernameController = useTextEditingController();
|
||||||
final passwordController = useTextEditingController();
|
final passwordController = useTextEditingController();
|
||||||
|
|
||||||
// reverse the animation if the user is not logged in
|
|
||||||
void addServer() {
|
void addServer() {
|
||||||
var newServer = serverUriController.text.isEmpty
|
var newServer = serverUriController.text.isEmpty
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ part of 'router.dart';
|
||||||
|
|
||||||
class Routes {
|
class Routes {
|
||||||
static const home = 'home';
|
static const home = 'home';
|
||||||
static const onboarding = 'onboarding';
|
static const onboarding = _SimpleRoute(
|
||||||
|
pathName: 'login',
|
||||||
|
name: 'onboarding',
|
||||||
|
);
|
||||||
static const library = _SimpleRoute(
|
static const library = _SimpleRoute(
|
||||||
pathName: 'library',
|
pathName: 'library',
|
||||||
pathParamName: 'libraryId',
|
pathParamName: 'libraryId',
|
||||||
|
|
@ -15,6 +18,10 @@ class Routes {
|
||||||
pathParamName: 'itemId',
|
pathParamName: 'itemId',
|
||||||
name: 'libraryItem',
|
name: 'libraryItem',
|
||||||
);
|
);
|
||||||
|
static const settings = _SimpleRoute(
|
||||||
|
pathName: 'config',
|
||||||
|
name: 'settings',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// a class to store path
|
// a class to store path
|
||||||
|
|
@ -22,13 +29,14 @@ class Routes {
|
||||||
class _SimpleRoute {
|
class _SimpleRoute {
|
||||||
const _SimpleRoute({
|
const _SimpleRoute({
|
||||||
required this.pathName,
|
required this.pathName,
|
||||||
required this.pathParamName,
|
this.pathParamName,
|
||||||
required this.name,
|
required this.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String pathName;
|
final String pathName;
|
||||||
final String pathParamName;
|
final String? pathParamName;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
String get path => '/$pathName/:$pathParamName';
|
String get path =>
|
||||||
|
'/$pathName${pathParamName != null ? '/:$pathParamName' : ''}';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,100 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:whispering_pages/pages/app_settings.dart';
|
||||||
import 'package:whispering_pages/pages/home_page.dart';
|
import 'package:whispering_pages/pages/home_page.dart';
|
||||||
import 'package:whispering_pages/pages/library_item_page.dart';
|
import 'package:whispering_pages/pages/library_item_page.dart';
|
||||||
import 'package:whispering_pages/pages/library_page.dart';
|
import 'package:whispering_pages/pages/library_page.dart';
|
||||||
import 'package:whispering_pages/pages/onboarding/onboarding_single_page.dart';
|
import 'package:whispering_pages/pages/onboarding/onboarding_single_page.dart';
|
||||||
|
|
||||||
|
import 'scaffold_with_nav_bar.dart';
|
||||||
|
import 'transitions/slide.dart';
|
||||||
|
|
||||||
part 'constants.dart';
|
part 'constants.dart';
|
||||||
|
|
||||||
|
final GlobalKey<NavigatorState> _rootNavigatorKey =
|
||||||
|
GlobalKey<NavigatorState>(debugLabel: 'root');
|
||||||
|
final GlobalKey<NavigatorState> _sectionANavigatorKey =
|
||||||
|
GlobalKey<NavigatorState>(debugLabel: 'sectionANav');
|
||||||
|
|
||||||
// GoRouter configuration
|
// GoRouter configuration
|
||||||
class MyAppRouter {
|
class MyAppRouter {
|
||||||
const MyAppRouter({required this.needOnboarding});
|
const MyAppRouter();
|
||||||
final bool needOnboarding;
|
|
||||||
// some static strings for named routes
|
|
||||||
|
|
||||||
GoRouter get config => GoRouter(
|
GoRouter get config => GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
|
// sign in page
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: Routes.onboarding.path,
|
||||||
name: Routes.onboarding,
|
name: Routes.onboarding.name,
|
||||||
builder: (context, state) => const OnboardingSinglePage(),
|
builder: (context, state) => const OnboardingSinglePage(),
|
||||||
),
|
),
|
||||||
|
StatefulShellRoute.indexedStack(
|
||||||
|
builder: (
|
||||||
|
BuildContext context,
|
||||||
|
GoRouterState state,
|
||||||
|
StatefulNavigationShell navigationShell,
|
||||||
|
) {
|
||||||
|
// Return the widget that implements the custom shell (in this case
|
||||||
|
// using a BottomNavigationBar). The StatefulNavigationShell is passed
|
||||||
|
// to be able access the state of the shell and to navigate to other
|
||||||
|
// branches in a stateful way.
|
||||||
|
return ScaffoldWithNavBar(navigationShell: navigationShell);
|
||||||
|
},
|
||||||
|
branches: <StatefulShellBranch>[
|
||||||
|
// The route branch for the first tab of the bottom navigation bar.
|
||||||
|
StatefulShellBranch(
|
||||||
|
navigatorKey: _sectionANavigatorKey,
|
||||||
|
routes: <RouteBase>[
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: Routes.home,
|
name: Routes.home,
|
||||||
builder: (context, state) => const HomePage(),
|
// builder: (context, state) => const HomePage(),
|
||||||
|
pageBuilder: defaultPageBuilder(const HomePage()),
|
||||||
),
|
),
|
||||||
// /library/:libraryId
|
// /library/:libraryId
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.library.path,
|
path: Routes.library.path,
|
||||||
name: Routes.library.name,
|
name: Routes.library.name,
|
||||||
builder: (context, state) => LibraryPage(
|
builder: (context, state) => LibraryPage(
|
||||||
libraryId: state.pathParameters[Routes.library.pathParamName]!,
|
libraryId:
|
||||||
|
state.pathParameters[Routes.library.pathParamName]!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.libraryItem.path,
|
path: Routes.libraryItem.path,
|
||||||
name: Routes.libraryItem.name,
|
name: Routes.libraryItem.name,
|
||||||
builder: (context, state) {
|
// builder: (context, state) {
|
||||||
final itemId =
|
// final itemId = state
|
||||||
state.pathParameters[Routes.libraryItem.pathParamName]!;
|
// .pathParameters[Routes.libraryItem.pathParamName]!;
|
||||||
return LibraryItemPage(itemId: itemId, extra: state.extra);
|
// return LibraryItemPage(
|
||||||
|
// itemId: itemId, extra: state.extra);
|
||||||
|
// },
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
final itemId = state
|
||||||
|
.pathParameters[Routes.libraryItem.pathParamName]!;
|
||||||
|
final child =
|
||||||
|
LibraryItemPage(itemId: itemId, extra: state.extra);
|
||||||
|
return buildPageWithDefaultTransition(
|
||||||
|
context: context,
|
||||||
|
state: state,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
redirect: (context, state) {
|
),
|
||||||
if (needOnboarding) {
|
StatefulShellBranch(
|
||||||
return config.namedLocation(Routes.onboarding);
|
routes: <RouteBase>[
|
||||||
}
|
GoRoute(
|
||||||
return null;
|
path: Routes.settings.path,
|
||||||
},
|
name: Routes.settings.name,
|
||||||
|
// builder: (context, state) => const AppSettingsPage(),
|
||||||
|
pageBuilder: defaultPageBuilder(const AppSettingsPage()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
lib/router/scaffold_with_nav_bar.dart
Normal file
65
lib/router/scaffold_with_nav_bar.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// Builds the "shell" for the app by building a Scaffold with a
|
||||||
|
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
|
||||||
|
class ScaffoldWithNavBar extends StatelessWidget {
|
||||||
|
/// Constructs an [ScaffoldWithNavBar].
|
||||||
|
const ScaffoldWithNavBar({
|
||||||
|
required this.navigationShell,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));
|
||||||
|
|
||||||
|
/// The navigation shell and container for the branch Navigators.
|
||||||
|
final StatefulNavigationShell navigationShell;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: navigationShell,
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
elevation: 0.0,
|
||||||
|
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
|
||||||
|
selectedFontSize: Theme.of(context).textTheme.labelMedium!.fontSize!,
|
||||||
|
unselectedFontSize: Theme.of(context).textTheme.labelMedium!.fontSize!,
|
||||||
|
// fixedColor: Theme.of(context).colorScheme.primary,
|
||||||
|
// type: BottomNavigationBarType.fixed,
|
||||||
|
|
||||||
|
// Here, the items of BottomNavigationBar are hard coded. In a real
|
||||||
|
// world scenario, the items would most likely be generated from the
|
||||||
|
// branches of the shell route, which can be fetched using
|
||||||
|
// `navigationShell.route.branches`.
|
||||||
|
items: const <BottomNavigationBarItem>[
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
label: 'Home',
|
||||||
|
icon: Icon(Icons.home_outlined),
|
||||||
|
activeIcon: Icon(Icons.home),
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
label: 'Settings',
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
activeIcon: Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
currentIndex: navigationShell.currentIndex,
|
||||||
|
onTap: (int index) => _onTap(context, index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to the current location of the branch at the provided index when
|
||||||
|
/// tapping an item in the BottomNavigationBar.
|
||||||
|
void _onTap(BuildContext context, int index) {
|
||||||
|
// When navigating to a new branch, it's recommended to use the goBranch
|
||||||
|
// method, as doing so makes sure the last navigation state of the
|
||||||
|
// Navigator for the branch is restored.
|
||||||
|
navigationShell.goBranch(
|
||||||
|
index,
|
||||||
|
// A common pattern when using bottom navigation bars is to support
|
||||||
|
// navigating to the initial location when tapping the item that is
|
||||||
|
// already active. This example demonstrates how to support this behavior,
|
||||||
|
// using the initialLocation parameter of goBranch.
|
||||||
|
initialLocation: index == navigationShell.currentIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/router/transitions/slide.dart
Normal file
61
lib/router/transitions/slide.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
// class CustomSlideTransition extends CustomTransitionPage<void> {
|
||||||
|
// CustomSlideTransition({super.key, required super.child})
|
||||||
|
// : super(
|
||||||
|
// transitionDuration: const Duration(milliseconds: 250),
|
||||||
|
// transitionsBuilder: (_, animation, __, child) {
|
||||||
|
// return SlideTransition(
|
||||||
|
// position: animation.drive(
|
||||||
|
// Tween(
|
||||||
|
// begin: const Offset(1.5, 0),
|
||||||
|
// end: Offset.zero,
|
||||||
|
// ).chain(
|
||||||
|
// CurveTween(curve: Curves.ease),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// child: child,
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
CustomTransitionPage buildPageWithDefaultTransition<T>({
|
||||||
|
required BuildContext context,
|
||||||
|
required GoRouterState state,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: state.pageKey,
|
||||||
|
transitionDuration: 250.ms,
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
|
FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: animation.drive(
|
||||||
|
Tween(
|
||||||
|
begin: const Offset(0, 1.50),
|
||||||
|
end: Offset.zero,
|
||||||
|
).chain(
|
||||||
|
CurveTween(curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<dynamic> Function(BuildContext, GoRouterState) defaultPageBuilder<T>(
|
||||||
|
Widget child,
|
||||||
|
) =>
|
||||||
|
(BuildContext context, GoRouterState state) {
|
||||||
|
return buildPageWithDefaultTransition<T>(
|
||||||
|
context: context,
|
||||||
|
state: state,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -23,7 +23,14 @@ class ApiSettings extends _$ApiSettings {
|
||||||
model.ApiSettings readFromBoxOrCreate() {
|
model.ApiSettings readFromBoxOrCreate() {
|
||||||
// see if the settings are already in the box
|
// see if the settings are already in the box
|
||||||
if (_box.isNotEmpty) {
|
if (_box.isNotEmpty) {
|
||||||
final foundSettings = _box.getAt(0);
|
var foundSettings = _box.getAt(0);
|
||||||
|
// foundSettings.activeServer ??= foundSettings.activeUser?.server;
|
||||||
|
// foundSettings =foundSettings.copyWith(activeServer: foundSettings.activeUser?.server);
|
||||||
|
if (foundSettings.activeServer == null) {
|
||||||
|
foundSettings = foundSettings.copyWith(
|
||||||
|
activeServer: foundSettings.activeUser?.server,
|
||||||
|
);
|
||||||
|
}
|
||||||
debugPrint('found api settings in box: $foundSettings');
|
debugPrint('found api settings in box: $foundSettings');
|
||||||
return foundSettings;
|
return foundSettings;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:whispering_pages/pages/app_settings.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:whispering_pages/pages/server_manager.dart';
|
import 'package:whispering_pages/pages/server_manager.dart';
|
||||||
|
import 'package:whispering_pages/router/router.dart';
|
||||||
|
|
||||||
|
|
||||||
class MyDrawer extends StatelessWidget {
|
class MyDrawer extends StatelessWidget {
|
||||||
|
|
@ -35,11 +36,7 @@ class MyDrawer extends StatelessWidget {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('App Settings'),
|
title: const Text('App Settings'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
context.goNamed(Routes.settings.name);
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const AppSettingsPage(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -72,13 +72,14 @@ class BookOnShelf extends HookConsumerWidget {
|
||||||
// the cover image of the book
|
// the cover image of the book
|
||||||
// take up remaining space
|
// take up remaining space
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: Center(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// open the book
|
// open the book
|
||||||
context.pushNamed(
|
context.pushNamed(
|
||||||
Routes.libraryItem.name,
|
Routes.libraryItem.name,
|
||||||
pathParameters: {
|
pathParameters: {
|
||||||
Routes.libraryItem.pathParamName: item.id,
|
Routes.libraryItem.pathParamName!: item.id,
|
||||||
},
|
},
|
||||||
extra: LibraryItemExtras(
|
extra: LibraryItemExtras(
|
||||||
book: book,
|
book: book,
|
||||||
|
|
@ -95,18 +96,25 @@ class BookOnShelf extends HookConsumerWidget {
|
||||||
if (image.isEmpty) {
|
if (image.isEmpty) {
|
||||||
return const Icon(Icons.error);
|
return const Icon(Icons.error);
|
||||||
}
|
}
|
||||||
// cover 80% of parent height
|
var imageWidget = Image.memory(
|
||||||
return Hero(
|
|
||||||
tag: HeroTagPrefixes.bookCover +
|
|
||||||
item.id +
|
|
||||||
heroTagSuffix,
|
|
||||||
child: Image.memory(
|
|
||||||
image,
|
image,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.fill,
|
||||||
cacheWidth: (height *
|
cacheWidth: (height *
|
||||||
1.2 *
|
1.2 *
|
||||||
MediaQuery.of(context).devicePixelRatio)
|
MediaQuery.of(context).devicePixelRatio)
|
||||||
.round(),
|
.round(),
|
||||||
|
);
|
||||||
|
return Hero(
|
||||||
|
tag: HeroTagPrefixes.bookCover +
|
||||||
|
item.id +
|
||||||
|
heroTagSuffix,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
child: imageWidget,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -120,6 +128,7 @@ class BookOnShelf extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
// the title and author of the book
|
// the title and author of the book
|
||||||
// AutoScrollText(
|
// AutoScrollText(
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue