Responsive Navigation for tablet & small devices

Responsive design utilites created
This commit is contained in:
Kingkor Roy Tirtho 2022-02-26 10:56:32 +06:00
parent 5b389564c1
commit 584f431b04
13 changed files with 423 additions and 196 deletions

View File

@ -1,12 +1,14 @@
import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Playlist/PlaylistCard.dart';
import 'package:spotube/components/Playlist/PlaylistGenreView.dart';
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
import 'package:spotube/provider/SpotifyDI.dart';
class CategoryCard extends StatelessWidget {
class CategoryCard extends HookWidget {
final Category category;
final Iterable<PlaylistSimple>? playlists;
const CategoryCard(
@ -45,9 +47,10 @@ class CategoryCard extends StatelessWidget {
],
),
),
Consumer(
HookConsumer(
builder: (context, ref, child) {
SpotifyApi spotifyApi = ref.watch(spotifyProvider);
final scrollController = useScrollController();
return FutureBuilder<Iterable<PlaylistSimple>>(
future: playlists == null
? (category.id != "user-featured-playlists"
@ -65,12 +68,18 @@ class CategoryCard extends StatelessWidget {
child: CircularProgressIndicator.adaptive(),
);
}
return Wrap(
spacing: 20,
runSpacing: 20,
return Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: snapshot.data!
.map((playlist) => PlaylistCard(playlist))
.toList(),
),
),
);
});
},

View File

@ -2,9 +2,9 @@ import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:oauth2/oauth2.dart' show AuthorizationException;
import 'package:spotify/spotify.dart' hide Image, Player, Search;
@ -18,6 +18,9 @@ import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Player/Player.dart';
import 'package:spotube/components/Library/UserLibrary.dart';
import 'package:spotube/helpers/oauth-login.dart';
import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/hooks/usePagingController.dart';
import 'package:spotube/hooks/useSharedPreferences.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/SpotifyDI.dart';
@ -32,40 +35,70 @@ List<String> spotifyScopes = [
"playlist-read-collaborative"
];
class Home extends ConsumerStatefulWidget {
class Home extends HookConsumerWidget {
const Home({Key? key}) : super(key: key);
@override
_HomeState createState() => _HomeState();
}
Widget build(BuildContext context, ref) {
Auth auth = ref.watch(authProvider);
class _HomeState extends ConsumerState<Home> {
final PagingController<int, Category> _pagingController =
PagingController(firstPageKey: 0);
final pagingController =
usePagingController<int, Category>(firstPageKey: 0);
final int titleBarDragMaxWidth = useBreakpointValue(
md: 72,
lg: 256,
sm: 0,
xl: 0,
xxl: 0,
);
final _selectedIndex = useState(0);
_onSelectedIndexChanged(int index) => _selectedIndex.value = index;
int _selectedIndex = 0;
final localStorage = useSharedPreferences();
@override
void initState() {
super.initState();
WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async {
SharedPreferences localStorage = await SharedPreferences.getInstance();
String? clientId = localStorage.getString(LocalStorageKeys.clientId);
String? clientSecret =
useEffect(() {
if (localStorage == null) return null;
final String? clientId =
localStorage.getString(LocalStorageKeys.clientId);
final String? clientSecret =
localStorage.getString(LocalStorageKeys.clientSecret);
String? accessToken =
final String? accessToken =
localStorage.getString(LocalStorageKeys.accessToken);
String? refreshToken =
final String? refreshToken =
localStorage.getString(LocalStorageKeys.refreshToken);
String? expirationStr =
final String? expirationStr =
localStorage.getString(LocalStorageKeys.expiration);
DateTime? expiration =
expirationStr != null ? DateTime.parse(expirationStr) : null;
listener(pageKey) async {
final spotify = ref.read(spotifyProvider);
try {
Auth auth = ref.read(authProvider);
Page<Category> categories =
await spotify.categories.list(country: "US").getPage(15, pageKey);
var items = categories.items!.toList();
if (pageKey == 0) {
Category category = Category();
category.id = "user-featured-playlists";
category.name = "Featured";
items.insert(0, category);
}
if (categories.isLast && categories.items != null) {
pagingController.appendLastPage(items);
} else if (categories.items != null) {
pagingController.appendPage(items, categories.nextOffset);
}
} catch (e, stack) {
pagingController.error = e;
print("[Home.pagingController.addPageRequestListener] $e");
print(stack);
}
}
try {
final DateTime? expiration =
expirationStr != null ? DateTime.parse(expirationStr) : null;
if (clientId != null && clientSecret != null) {
SpotifyApi spotifyApi = SpotifyApi(
SpotifyApi spotify = SpotifyApi(
SpotifyApiCredentials(
clientId,
clientSecret,
@ -75,7 +108,7 @@ class _HomeState extends ConsumerState<Home> {
scopes: spotifyScopes,
),
);
SpotifyApiCredentials credentials = await spotifyApi.getCredentials();
spotify.getCredentials().then((credentials) {
if (credentials.accessToken?.isNotEmpty ?? false) {
auth.setAuthState(
clientId: clientId,
@ -87,35 +120,15 @@ class _HomeState extends ConsumerState<Home> {
isLoggedIn: true,
);
}
}
_pagingController.addPageRequestListener((pageKey) async {
try {
SpotifyApi spotifyApi = ref.read(spotifyProvider);
Page<Category> categories = await spotifyApi.categories
.list(country: "US")
.getPage(15, pageKey);
var items = categories.items!.toList();
if (pageKey == 0) {
Category category = Category();
category.id = "user-featured-playlists";
category.name = "Featured";
items.insert(0, category);
}
if (categories.isLast && categories.items != null) {
_pagingController.appendLastPage(items);
} else if (categories.items != null) {
_pagingController.appendPage(items, categories.nextOffset);
}
} catch (e) {
_pagingController.error = e;
}
return null;
}).then((_) {
pagingController.addPageRequestListener(listener);
});
}
} on AuthorizationException catch (_) {
if (clientId != null && clientSecret != null) {
oauthLogin(
ref.read(authProvider),
auth,
clientId: clientId,
clientSecret: clientSecret,
);
@ -124,23 +137,11 @@ class _HomeState extends ConsumerState<Home> {
print("[Home.initState]: $e");
print(stack);
}
});
}
return () {
pagingController.removePageRequestListener(listener);
};
}, [localStorage]);
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
_onSelectedIndexChanged(int index) => setState(() {
_selectedIndex = index;
});
@override
Widget build(BuildContext context) {
Auth auth = ref.watch(authProvider);
final width = MediaQuery.of(context).size.width;
if (!auth.isLoggedIn) {
return const Login();
}
@ -156,11 +157,8 @@ class _HomeState extends ConsumerState<Home> {
children: [
Container(
constraints: BoxConstraints(
maxWidth: width > 400 && width <= 700
? 72
: width > 700
? 256
: 0),
maxWidth: titleBarDragMaxWidth.toDouble(),
),
color:
Theme.of(context).navigationRailTheme.backgroundColor,
child: MoveWindow(),
@ -176,16 +174,16 @@ class _HomeState extends ConsumerState<Home> {
child: Row(
children: [
Sidebar(
selectedIndex: _selectedIndex,
selectedIndex: _selectedIndex.value,
onSelectedIndexChanged: _onSelectedIndexChanged,
),
// contents of the spotify
if (_selectedIndex == 0)
if (_selectedIndex.value == 0)
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PagedListView(
pagingController: _pagingController,
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate<Category>(
itemBuilder: (context, item, index) {
return CategoryCard(item);
@ -194,16 +192,16 @@ class _HomeState extends ConsumerState<Home> {
),
),
),
if (_selectedIndex == 1) const Search(),
if (_selectedIndex == 2) const UserLibrary(),
if (_selectedIndex == 3) const Lyrics(),
if (_selectedIndex.value == 1) const Search(),
if (_selectedIndex.value == 2) const UserLibrary(),
if (_selectedIndex.value == 3) const Lyrics(),
],
),
),
// player itself
const Player(),
SpotubeNavigationBar(
selectedIndex: _selectedIndex,
selectedIndex: _selectedIndex.value,
onSelectedIndexChanged: _onSelectedIndexChanged,
),
],

View File

@ -6,6 +6,7 @@ import 'package:spotify/spotify.dart' hide Image;
import 'package:spotube/components/Settings.dart';
import 'package:spotube/components/Shared/SpotubePageRoute.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import '../../models/sideBarTiles.dart';
@ -28,7 +29,7 @@ class Sidebar extends HookConsumerWidget {
);
}
void _goToSettings(BuildContext context) {
static void goToSettings(BuildContext context) {
Navigator.of(context).push(SpotubePageRoute(
child: const Settings(),
));
@ -36,21 +37,25 @@ class Sidebar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final width = MediaQuery.of(context).size.width;
if (width <= 400) return Container();
final breakpoints = useBreakpoints();
if (breakpoints.isSm) return Container();
final extended = useState(false);
final SpotifyApi spotify = ref.watch(spotifyProvider);
useEffect(() {
if (width <= 700 && extended.value) {
if (breakpoints.isMd && extended.value) {
extended.value = false;
} else if (width > 700 && !extended.value) {
} else if (breakpoints.isMoreThanOrEqualTo(Breakpoints.lg) &&
!extended.value) {
extended.value = true;
}
return null;
});
return NavigationRail(
destinations: sidebarTileList
.map((e) => NavigationRailDestination(
.map(
(e) => NavigationRailDestination(
icon: Icon(e.icon),
label: Text(
e.title,
@ -59,7 +64,8 @@ class Sidebar extends HookConsumerWidget {
fontSize: 16,
),
),
))
),
)
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: onSelectedIndexChanged,
@ -104,11 +110,11 @@ class Sidebar extends HookConsumerWidget {
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => _goToSettings(context)),
onPressed: () => goToSettings(context)),
],
))
: InkWell(
onTap: () => _goToSettings(context),
onTap: () => goToSettings(context),
child: CircleAvatar(
backgroundImage: CachedNetworkImageProvider(avatarImg),
),

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/components/Home/Sidebar.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/sideBarTiles.dart';
class SpotubeNavigationBar extends HookWidget {
@ -14,17 +16,21 @@ class SpotubeNavigationBar extends HookWidget {
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final breakpoint = useBreakpoints();
if (width > 400) return Container();
if (breakpoint.isMoreThan(Breakpoints.sm)) return Container();
return NavigationBar(
destinations: sidebarTileList
.map(
destinations: [
...sidebarTileList.map(
(e) => NavigationDestination(icon: Icon(e.icon), label: e.title),
),
const NavigationDestination(
icon: Icon(Icons.settings_rounded),
label: "Settings",
)
.toList(),
],
selectedIndex: selectedIndex,
onDestinationSelected: onSelectedIndexChanged,
onDestinationSelected: (i) => Sidebar.goToSettings(context),
);
}
}

View File

@ -17,6 +17,7 @@ class PlaylistCard extends ConsumerWidget {
bool isPlaylistPlaying = playback.currentPlaylist != null &&
playback.currentPlaylist!.id == playlist.id;
return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 20),
title: playlist.name!,
imageUrl: playlist.images![0].url!,
isPlaying: isPlaylistPlaying,

View File

@ -5,6 +5,7 @@ class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final String? description;
final EdgeInsetsGeometry? margin;
final String imageUrl;
final bool isPlaying;
final String title;
@ -12,6 +13,7 @@ class PlaybuttonCard extends StatelessWidget {
required this.imageUrl,
required this.isPlaying,
required this.title,
this.margin,
this.description,
this.onPlaybuttonPressed,
this.onTap,
@ -20,7 +22,9 @@ class PlaybuttonCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
return Container(
margin: margin,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
@ -102,6 +106,7 @@ class PlaybuttonCard extends StatelessWidget {
),
),
),
),
);
}
}

View File

@ -0,0 +1,18 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
void useAsyncEffect(
FutureOr<dynamic> Function() effect, [
FutureOr<dynamic> Function()? cleanup,
List<Object>? keys,
]) {
useEffect(() {
Future.microtask(effect);
return () {
if (cleanup != null) {
Future.microtask(cleanup);
}
};
}, keys);
}

View File

@ -0,0 +1,17 @@
import 'package:spotube/hooks/useBreakpoints.dart';
useBreakpointValue({sm, md, lg, xl, xxl}) {
final breakpoint = useBreakpoints();
if (breakpoint.isSm) {
return sm;
} else if (breakpoint.isMd) {
return md;
} else if (breakpoint.isXl) {
return xl;
} else if (breakpoint.isXxl) {
return xxl;
} else {
return lg;
}
}

View File

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class BreakpointUtils {
Breakpoints breakpoint;
List<Breakpoints> breakpointList = [
Breakpoints.sm,
Breakpoints.md,
Breakpoints.lg,
Breakpoints.xl,
Breakpoints.xxl
];
BreakpointUtils(this.breakpoint);
get isSm => breakpoint == Breakpoints.sm;
get isMd => breakpoint == Breakpoints.md;
get isLg => breakpoint == Breakpoints.lg;
get isXl => breakpoint == Breakpoints.xl;
get isXxl => breakpoint == Breakpoints.xxl;
bool isMoreThanOrEqualTo(Breakpoints b) {
return breakpointList
.sublist(breakpointList.indexOf(b))
.contains(breakpoint);
}
bool isLessThanOrEqualTo(Breakpoints b) {
return breakpointList
.sublist(0, breakpointList.indexOf(b) + 1)
.contains(breakpoint);
}
bool isMoreThan(Breakpoints b) {
return breakpointList
.sublist(breakpointList.indexOf(b) + 1)
.contains(breakpoint);
}
bool isLessThan(Breakpoints b) {
return breakpointList
.sublist(0, breakpointList.indexOf(b))
.contains(breakpoint);
}
bool operator >(other) {
return isMoreThan(other);
}
bool operator <(other) {
return isLessThan(other);
}
bool operator >=(other) {
return isMoreThanOrEqualTo(other);
}
bool operator <=(other) {
return isLessThanOrEqualTo(other);
}
}
enum Breakpoints { sm, md, lg, xl, xxl }
BreakpointUtils useBreakpoints() {
final context = useContext();
final width = MediaQuery.of(context).size.width;
final breakpoint = useState(Breakpoints.lg);
final utils = BreakpointUtils(breakpoint.value);
useEffect(() {
if (width >= 1920 && breakpoint.value != Breakpoints.xxl) {
breakpoint.value = Breakpoints.xxl;
} else if (width >= 1366 &&
width < 1920 &&
breakpoint.value != Breakpoints.xl) {
breakpoint.value = Breakpoints.xl;
} else if (width >= 768 &&
width < 1366 &&
breakpoint.value != Breakpoints.lg) {
breakpoint.value = Breakpoints.lg;
} else if (width >= 360 &&
width < 768 &&
breakpoint.value != Breakpoints.md) {
breakpoint.value = Breakpoints.md;
} else if (width >= 250 &&
width < 360 &&
breakpoint.value != Breakpoints.sm) {
breakpoint.value = Breakpoints.sm;
}
return null;
}, [width]);
useEffect(() {
utils.breakpoint = breakpoint.value;
return null;
}, [breakpoint.value]);
return utils;
}

View File

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
PagingController<PageKeyType, ItemType>
usePagingController<PageKeyType, ItemType>({
required final PageKeyType firstPageKey,
final int? invisibleItemsThreshold,
List<Object?>? keys,
}) {
return use(
_PagingControllerHook<PageKeyType, ItemType>(
firstPageKey: firstPageKey,
invisibleItemsThreshold: invisibleItemsThreshold,
keys: keys,
),
);
}
class _PagingControllerHook<PageKeyType, ItemType>
extends Hook<PagingController<PageKeyType, ItemType>> {
const _PagingControllerHook({
required this.firstPageKey,
this.invisibleItemsThreshold,
List<Object?>? keys,
}) : super(keys: keys);
final PageKeyType firstPageKey;
final int? invisibleItemsThreshold;
@override
HookState<PagingController<PageKeyType, ItemType>,
Hook<PagingController<PageKeyType, ItemType>>>
createState() => _PagingControllerHookState<PageKeyType, ItemType>();
}
class _PagingControllerHookState<PageKeyType, ItemType> extends HookState<
PagingController<PageKeyType, ItemType>,
_PagingControllerHook<PageKeyType, ItemType>> {
late final controller = PagingController<PageKeyType, ItemType>(
firstPageKey: hook.firstPageKey,
invisibleItemsThreshold: hook.invisibleItemsThreshold);
@override
PagingController<PageKeyType, ItemType> build(BuildContext context) =>
controller;
@override
void dispose() => controller.dispose();
@override
String get debugLabel => 'usePagingController';
}

View File

@ -0,0 +1,9 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shared_preferences/shared_preferences.dart';
SharedPreferences? useSharedPreferences() {
final future = useMemoized(SharedPreferences.getInstance);
final snapshot = useFuture(future, initialData: null);
return snapshot.data;
}

View File

@ -52,6 +52,11 @@ class Auth with ChangeNotifier {
_isLoggedIn = false;
notifyListeners();
}
@override
String toString() {
return "Auth(clientId: $clientId, clientSecret: $clientSecret, accessToken: $accessToken, refreshToken: $refreshToken, expiration: $expiration, isLoggedIn: $isLoggedIn)";
}
}
var authProvider = ChangeNotifierProvider<Auth>((ref) => Auth());

View File

@ -7,6 +7,7 @@ import 'package:spotube/provider/Auth.dart';
var spotifyProvider = Provider<SpotifyApi>((ref) {
Auth authState = ref.watch(authProvider);
return SpotifyApi(
SpotifyApiCredentials(
authState.clientId,