diff --git a/assets/spotube-logo.png b/assets/spotube-logo.png new file mode 100644 index 00000000..0793df19 Binary files /dev/null and b/assets/spotube-logo.png differ diff --git a/lib/components/Home.dart b/lib/components/Home.dart index 463c76ba..a50e4dad 100644 --- a/lib/components/Home.dart +++ b/lib/components/Home.dart @@ -1,15 +1,26 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart' hide Page; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Image; import 'package:spotube/components/CategoryCard.dart'; import 'package:spotube/components/Login.dart'; import 'package:spotube/components/Player.dart' as player; +import 'package:spotube/components/Settings.dart'; +import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/sideBarTiles.dart'; import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/SpotifyDI.dart'; +List spotifyScopes = [ + "user-library-read", + "user-library-modify", + "user-read-private", + "user-read-email", + "playlist-read-collaborative" +]; + class Home extends StatefulWidget { const Home({Key? key}) : super(key: key); @@ -21,6 +32,8 @@ class _HomeState extends State { final PagingController _pagingController = PagingController(firstPageKey: 0); + int _selectedIndex = 0; + @override void initState() { super.initState(); @@ -28,24 +41,36 @@ class _HomeState extends State { try { Auth authProvider = context.read(); SharedPreferences localStorage = await SharedPreferences.getInstance(); - String? clientId = localStorage.getString('client_id'); - String? clientSecret = localStorage.getString('client_secret'); + var clientId = localStorage.getString(LocalStorageKeys.clientId); + var clientSecret = + localStorage.getString(LocalStorageKeys.clientSecret); + var accessToken = localStorage.getString(LocalStorageKeys.accessToken); + var refreshToken = + localStorage.getString(LocalStorageKeys.refreshToken); + var expirationStr = localStorage.getString(LocalStorageKeys.expiration); + var expiration = + expirationStr != null ? DateTime.parse(expirationStr) : null; if (clientId != null && clientSecret != null) { SpotifyApi spotifyApi = SpotifyApi( - SpotifyApiCredentials(clientId, clientSecret, scopes: [ - "user-library-read", - "user-library-modify", - "user-read-private", - "user-read-email", - "playlist-read-collaborative" - ]), + SpotifyApiCredentials( + clientId, + clientSecret, + accessToken: accessToken, + refreshToken: refreshToken, + expiration: expiration, + scopes: spotifyScopes, + ), ); SpotifyApiCredentials credentials = await spotifyApi.getCredentials(); if (credentials.accessToken?.isNotEmpty ?? false) { authProvider.setAuthState( - clientId: credentials.clientId, - clientSecret: credentials.clientSecret, + clientId: clientId, + clientSecret: clientSecret, + accessToken: + credentials.accessToken, // accessToken can be new/refreshed + refreshToken: refreshToken, + expiration: credentials.expiration, isLoggedIn: true, ); } @@ -89,87 +114,98 @@ class _HomeState extends State { return Scaffold( body: Column( children: [ - // Side Tab Bar Expanded( child: Row( children: [ - Container( - color: Colors.blueGrey[50], - constraints: const BoxConstraints(maxWidth: 230), - child: Material( - type: MaterialType.transparency, - child: Column( - children: [ - Flexible( - flex: 1, - // TabButtons - child: Column( - children: [ - ListTile( - title: Text("Spotube", - style: - Theme.of(context).textTheme.headline4), - leading: - const Icon(Icons.miscellaneous_services), + NavigationRail( + backgroundColor: Colors.blueGrey[50], + destinations: sidebarTileList + .map((e) => NavigationRailDestination( + icon: Icon(e.icon), + label: Text( + e.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, ), - const SizedBox(height: 20), - ...sidebarTileList - .map( - (sidebarTile) => ListTile( - title: Text(sidebarTile.title), - leading: Icon(sidebarTile.icon), - onTap: () {}, - ), - ) - .toList(), + ), + )) + .toList(), + selectedIndex: _selectedIndex, + onDestinationSelected: (value) => setState(() { + _selectedIndex = value; + }), + extended: true, + leading: Padding( + padding: const EdgeInsets.only(left: 15), + child: Row(children: [ + Image.asset( + "assets/spotube-logo.png", + height: 50, + width: 50, + ), + const SizedBox( + width: 10, + ), + Text("Spotube", + style: Theme.of(context).textTheme.headline4), + ]), + ), + trailing: + Consumer(builder: (context, data, widget) { + return FutureBuilder( + future: data.spotifyApi.me.get(), + builder: (context, snapshot) { + var avatarImg = snapshot.data?.images?.last.url; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (avatarImg != null) + CircleAvatar( + child: CachedNetworkImage( + imageUrl: avatarImg, + ), + ), + Text( + snapshot.data?.displayName ?? "User's name", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () { + Navigator.of(context) + .push(MaterialPageRoute( + builder: (context) { + return const Settings(); + }, + )); + }), ], ), - ), - // user name & settings - Consumer(builder: (context, data, widget) { - return FutureBuilder( - future: data.spotifyApi.me.get(), - builder: (context, snapshot) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - snapshot.data?.displayName ?? - "User's name", - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - IconButton( - icon: - const Icon(Icons.settings_outlined), - onPressed: () {}), - ], - ), - ); - }, - ); - }) - ], - ), - ), + ); + }, + ); + }), ), // contents of the spotify - Expanded( - child: Scrollbar( - child: PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - return CategoryCard(item); - }, + if (_selectedIndex == 0) + Expanded( + child: Scrollbar( + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return CategoryCard(item); + }, + ), ), ), ), - ), + // player itself ], ), ), diff --git a/lib/components/Login.dart b/lib/components/Login.dart index 88eb2a1b..5fee5046 100644 --- a/lib/components/Login.dart +++ b/lib/components/Login.dart @@ -2,8 +2,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Image; +import 'package:spotube/components/Home.dart'; import 'package:spotube/helpers/server_ipc.dart'; +import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/provider/Auth.dart'; class Login extends StatefulWidget { @@ -12,38 +14,65 @@ class Login extends StatefulWidget { } class _LoginState extends State { - String client_id = ""; - String client_secret = ""; + String clientId = ""; + String clientSecret = ""; bool _fieldError = false; + String? accessToken; + String? refreshToken; + DateTime? expiration; handleLogin(Auth authState) async { try { - if (client_id == "" || client_secret == "") { + if (clientId == "" || clientSecret == "") { return setState(() { _fieldError = true; }); } - final credentials = SpotifyApiCredentials(client_id, client_secret); + final credentials = SpotifyApiCredentials(clientId, clientSecret); final grant = SpotifyApi.authorizationCodeGrant(credentials); - final redirectUri = "http://localhost:4304/auth/spotify/callback"; - final scopes = ["user-library-read", "user-library-modify"]; + const redirectUri = "http://localhost:4304/auth/spotify/callback"; - final authUri = - grant.getAuthorizationUrl(Uri.parse(redirectUri), scopes: scopes); + final authUri = grant.getAuthorizationUrl(Uri.parse(redirectUri), + scopes: spotifyScopes); final responseUri = await connectIpc(authUri.toString(), redirectUri); + SharedPreferences localStorage = await SharedPreferences.getInstance(); if (responseUri != null) { final SpotifyApi spotify = SpotifyApi.fromAuthCodeGrant(grant, responseUri); + var credentials = await spotify.getCredentials(); + if (credentials.accessToken != null) { + accessToken = credentials.accessToken; + await localStorage.setString( + LocalStorageKeys.accessToken, credentials.accessToken!); + } + if (credentials.refreshToken != null) { + refreshToken = credentials.refreshToken; + await localStorage.setString( + LocalStorageKeys.refreshToken, credentials.refreshToken!); + } + if (credentials.expiration != null) { + expiration = credentials.expiration; + await localStorage.setString(LocalStorageKeys.expiration, + credentials.expiration?.toString() ?? ""); + } } - SharedPreferences localStorage = await SharedPreferences.getInstance(); - await localStorage.setString('client_id', client_id); - await localStorage.setString('client_secret', client_secret); + await localStorage.setString(LocalStorageKeys.clientId, clientId); + await localStorage.setString( + LocalStorageKeys.clientSecret, + clientSecret, + ); authState.setAuthState( - clientId: client_id, clientSecret: client_secret, isLoggedIn: true); + clientId: clientId, + clientSecret: clientSecret, + accessToken: accessToken, + refreshToken: refreshToken, + expiration: expiration, + isLoggedIn: true, + ); } catch (e) { - print(e); + print("[Login.handleLogin] $e"); } } @@ -57,6 +86,11 @@ class _LoginState extends State { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ + Image.asset( + "assets/spotube-logo.png", + width: 400, + height: 400, + ), Text("Add your spotify credentials to get started", style: Theme.of(context).textTheme.headline4), const Text( @@ -77,7 +111,7 @@ class _LoginState extends State { ), onChanged: (value) { setState(() { - client_id = value; + clientId = value; }); }, ), @@ -91,7 +125,7 @@ class _LoginState extends State { ), onChanged: (value) { setState(() { - client_secret = value; + clientSecret = value; }); }, ), diff --git a/lib/components/PlayerControls.dart b/lib/components/PlayerControls.dart index 46b4ba04..9a56d573 100644 --- a/lib/components/PlayerControls.dart +++ b/lib/components/PlayerControls.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mpv_dart/mpv_dart.dart'; +import 'package:spotube/helpers/zero-pad-num-str.dart'; class PlayerControls extends StatefulWidget { final MPVPlayer player; @@ -43,10 +44,6 @@ class _PlayerControlsState extends State { super.dispose(); } - String zeroPadNumStr(int input) { - return input < 10 ? "0$input" : input.toString(); - } - @override Widget build(BuildContext context) { var totalDuration = Duration(seconds: widget.duration.toInt()); diff --git a/lib/components/PlaylistView.dart b/lib/components/PlaylistView.dart index 1872f161..f09ca7b3 100644 --- a/lib/components/PlaylistView.dart +++ b/lib/components/PlaylistView.dart @@ -1,8 +1,11 @@ +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/TrackButton.dart'; import 'package:spotube/provider/SpotifyDI.dart'; class PlaylistView extends StatefulWidget { @@ -13,6 +16,70 @@ class PlaylistView extends StatefulWidget { } class _PlaylistViewState extends State { + List trackToTableRow(List tracks) { + return tracks.asMap().entries.map((track) { + var thumbnailUrl = track.value.album?.images?.last.url; + var duration = + "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + return (TableRow( + children: [ + TableCell( + child: Text( + track.key.toString(), + textAlign: TextAlign.center, + )), + TableCell( + child: Row( + children: [ + if (thumbnailUrl != null) + CachedNetworkImage( + imageUrl: thumbnailUrl, + maxHeightDiskCache: 40, + maxWidthDiskCache: 40, + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.value.name ?? "", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 17, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + (track.value.artists ?? []) + .map((e) => e.name) + .join(", "), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + TableCell( + child: Text( + track.value.album?.name ?? "", + overflow: TextOverflow.ellipsis, + ), + ), + TableCell( + child: Text( + duration, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ) + ], + )); + }).toList(); + } + @override Widget build(BuildContext context) { return Consumer(builder: (_, data, __) { @@ -23,6 +90,8 @@ class _PlaylistViewState extends State { .all(), builder: (context, snapshot) { List tracks = snapshot.data?.toList() ?? []; + TextStyle tableHeadStyle = + const TextStyle(fontWeight: FontWeight.bold, fontSize: 16); return Column( children: [ Row( @@ -72,39 +141,49 @@ class _PlaylistViewState extends State { ? const CircularProgressIndicator.adaptive() : Expanded( child: Scrollbar( - isAlwaysShown: true, - child: ListView.builder( - itemCount: tracks.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return Column( - children: [ - TrackButton( - index: "#", - trackName: "Title", - artists: ["Artist"], - album: "Album", - playback_time: "Time"), - const Divider() - ], - ); - } - Track track = tracks[index - 1]; - return TrackButton( - index: (index - 1).toString(), - thumbnail_url: track - .album?.images?.last.url ?? - "https://i.scdn.co/image/ab67616d00001e02b993cba8ff7d0a8e9ee18d46", - trackName: track.name!, - artists: track.artists! - .map((e) => e.name!) - .toList(), - album: track.album!.name!, - playback_time: track.duration!.inMinutes - .toString(), - onTap: () {}, - ); - }), + child: ListView( + children: [ + SingleChildScrollView( + child: Table( + columnWidths: const { + 0: FixedColumnWidth(40), + 1: FlexColumnWidth(), + 2: FlexColumnWidth(), + 3: FixedColumnWidth(40), + }, + children: [ + TableRow( + children: [ + TableCell( + child: Text( + "#", + textAlign: TextAlign.center, + style: tableHeadStyle, + )), + TableCell( + child: Text( + "Title", + style: tableHeadStyle, + )), + TableCell( + child: Text( + "Album", + style: tableHeadStyle, + )), + TableCell( + child: Text( + "Time", + textAlign: TextAlign.center, + style: tableHeadStyle, + )), + ], + ), + ...trackToTableRow(tracks), + ], + ), + ), + ], + ), ), ), ], diff --git a/lib/components/Settings.dart b/lib/components/Settings.dart new file mode 100644 index 00000000..a36e6267 --- /dev/null +++ b/lib/components/Settings.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/provider/Auth.dart'; + +class Settings extends StatefulWidget { + const Settings({Key? key}) : super(key: key); + + @override + _SettingsState createState() => _SettingsState(); +} + +class _SettingsState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + iconTheme: Theme.of(context).iconTheme, + title: const Text( + "Settings", + ), + centerTitle: true, + titleTextStyle: Theme.of(context).textTheme.headline4, + ), + body: Column( + children: [ + Builder(builder: (context) { + var auth = context.read(); + return ElevatedButton( + child: const Text("Logout"), + onPressed: () async { + SharedPreferences localStorage = + await SharedPreferences.getInstance(); + await localStorage.clear(); + auth.logout(); + Navigator.of(context).pop(); + }, + ); + }) + ], + ), + ); + } +} diff --git a/lib/components/TrackButton.dart b/lib/components/TrackButton.dart deleted file mode 100644 index fc361e18..00000000 --- a/lib/components/TrackButton.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; - -class TrackButton extends StatefulWidget { - final String index; - final String trackName; - final List artists; - final String album; - final String playback_time; - final String? thumbnail_url; - final void Function()? onTap; - TrackButton({ - required this.index, - required this.trackName, - required this.artists, - required this.album, - required this.playback_time, - this.thumbnail_url, - this.onTap, - }); - - @override - _TrackButtonState createState() => _TrackButtonState(); -} - -class _TrackButtonState extends State { - @override - Widget build(BuildContext context) { - return Material( - child: InkWell( - onTap: widget.onTap, - child: Ink( - padding: const EdgeInsets.all(10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Text( - widget.index, - style: const TextStyle(fontSize: 20), - ), - const SizedBox(width: 15), - if (widget.thumbnail_url != null) - CachedNetworkImage( - imageUrl: widget.thumbnail_url!, - maxHeightDiskCache: 50, - maxWidthDiskCache: 50, - ), - const SizedBox(width: 15), - Container( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.trackName, - textAlign: TextAlign.justify, - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 17), - ), - Text(widget.artists.join(", ")) - ], - ), - ), - ], - ), - const SizedBox(width: 15), - Text(widget.album), - const SizedBox(width: 15), - Text(widget.playback_time) - ], - ), - ), - ), - ); - } -} diff --git a/lib/helpers/zero-pad-num-str.dart b/lib/helpers/zero-pad-num-str.dart new file mode 100644 index 00000000..f6c0ce10 --- /dev/null +++ b/lib/helpers/zero-pad-num-str.dart @@ -0,0 +1,3 @@ +String zeroPadNumStr(int input) { + return input < 10 ? "0$input" : input.toString(); +} diff --git a/lib/main.dart b/lib/main.dart index 80616a71..478c6220 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Home.dart'; +import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; @@ -18,8 +20,36 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider(create: (context) => Auth()), ChangeNotifierProvider(create: (context) { Auth authState = Provider.of(context, listen: false); - return SpotifyDI(SpotifyApi(SpotifyApiCredentials( - authState.cliendId, authState.clientSecret))); + return SpotifyDI( + SpotifyApi( + SpotifyApiCredentials( + authState.clientId, + authState.clientSecret, + accessToken: authState.accessToken, + refreshToken: authState.refreshToken, + expiration: authState.expiration, + scopes: spotifyScopes, + ), + onCredentialsRefreshed: (credentials) async { + SharedPreferences localStorage = + await SharedPreferences.getInstance(); + localStorage.setString( + LocalStorageKeys.refreshToken, + credentials.refreshToken!, + ); + localStorage.setString( + LocalStorageKeys.accessToken, + credentials.accessToken!, + ); + localStorage.setString( + LocalStorageKeys.clientId, credentials.clientId!); + localStorage.setString( + LocalStorageKeys.clientSecret, + credentials.clientSecret!, + ); + }, + ), + ); }), ChangeNotifierProvider(create: (context) => Playback()), ], diff --git a/lib/models/LocalStorageKeys.dart b/lib/models/LocalStorageKeys.dart new file mode 100644 index 00000000..65bbad25 --- /dev/null +++ b/lib/models/LocalStorageKeys.dart @@ -0,0 +1,7 @@ +abstract class LocalStorageKeys { + static String clientId = 'client_id'; + static String clientSecret = 'client_secret'; + static String accessToken = 'access_token'; + static String refreshToken = 'refresh_token'; + static String expiration = " expiration"; +} diff --git a/lib/provider/Auth.dart b/lib/provider/Auth.dart index b6af6b0e..5249b891 100644 --- a/lib/provider/Auth.dart +++ b/lib/provider/Auth.dart @@ -3,10 +3,17 @@ import 'package:flutter/cupertino.dart'; class Auth with ChangeNotifier { String? _clientId; String? _clientSecret; + String? _accessToken; + String? _refreshToken; + DateTime? _expiration; + bool _isLoggedIn = false; - String? get cliendId => _clientId; + String? get clientId => _clientId; String? get clientSecret => _clientSecret; + String? get accessToken => _accessToken; + String? get refreshToken => _refreshToken; + DateTime? get expiration => _expiration; bool get isLoggedIn => _isLoggedIn; void setAuthState({ @@ -14,17 +21,34 @@ class Auth with ChangeNotifier { bool safe = true, String? clientId, String? clientSecret, - String? refresh_token, - String? access_token, + String? refreshToken, + String? accessToken, + DateTime? expiration, }) { if (safe) { if (clientId != null) _clientId = clientId; if (clientSecret != null) _clientSecret = clientSecret; if (isLoggedIn != null) _isLoggedIn = isLoggedIn; + if (refreshToken != null) _refreshToken = refreshToken; + if (accessToken != null) _accessToken = accessToken; + if (expiration != null) _expiration = expiration; } else { _clientId = clientId; _clientSecret = clientSecret; + _accessToken = accessToken; + _refreshToken = refreshToken; + _expiration = expiration; } notifyListeners(); } + + logout() { + _clientId = null; + _clientSecret = null; + _accessToken = null; + _refreshToken = null; + _expiration = null; + _isLoggedIn = false; + notifyListeners(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index dab725e1..bf158dd5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,8 +68,8 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg + assets: + - assets/ # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see