diff --git a/lib/lyrics.dart b/lib/lyrics.dart index 7d91511..0205f27 100644 --- a/lib/lyrics.dart +++ b/lib/lyrics.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'dart:convert' as convert; import 'package:web_scraper/web_scraper.dart'; class LyricsPage extends StatefulWidget { @@ -87,8 +86,7 @@ class _LyricsPageState extends State { width: MediaQuery.of(context).size.width, child: SingleChildScrollView( child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 30, vertical: 30), + padding: const EdgeInsets.all(30), child: Text( lyrics ?? '', style: const TextStyle( diff --git a/lib/main.dart b/lib/main.dart index de32afc..66ad87a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,26 +1,59 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; import 'notification_listener.dart'; import 'search_result.dart'; import 'package:http/http.dart' as http; import 'dart:convert' as convert; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'settings.dart'; +import 'theme_provider.dart'; Future main() async { runApp(const LyricsGrabbr()); } -class LyricsGrabbr extends StatelessWidget { +class LyricsGrabbr extends StatefulWidget { const LyricsGrabbr({Key? key}) : super(key: key); + @override + State createState() => _LyricsGrabbrState(); +} + +class _LyricsGrabbrState extends State { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Lyrics Grabbr', - theme: ThemeData( - primarySwatch: Colors.purple, - ), - home: const HomePage(title: 'Lyrics Grabbr'), + return FutureBuilder( + future: SharedPreferences.getInstance(), + builder: (_, snapshot) { + final prefTheme = snapshot.data?.getString("theme"); + + var themeMode = ThemeMode.system; + if (prefTheme == AppTheme.Dark.name) { + themeMode = ThemeMode.dark; + } else if (prefTheme == AppTheme.Light.name) { + themeMode = ThemeMode.light; + } else if (prefTheme == AppTheme.System.name) { + themeMode = ThemeMode.system; + } + + return snapshot.hasData ? ChangeNotifierProvider( + create: (context) => ThemeProvider(themeMode), + builder: (context, _) { + final themeProvider = Provider.of(context); + + return MaterialApp( + title: 'Lyrics Grabbr', + theme: MyThemes.lightTheme, + darkTheme: MyThemes.darkTheme, + themeMode: themeProvider.themeMode, + home: const HomePage(title: 'Lyrics Grabbr'), + ); + } + ) : Container(); + } ); } } @@ -90,7 +123,6 @@ class _HomePageState extends State with WidgetsBindingObserver { ), content: const Text( 'This app requires permission to listen to notifications to function properly. Please grant permission in the next screen. BEWARE that the only way to stop the service is to revoke the notification listening permission.', - style: TextStyle(color: Colors.blueGrey), ), actions: [ TextButton( @@ -178,86 +210,111 @@ class _HomePageState extends State with WidgetsBindingObserver { } Widget _buildResults() { - return ListView.builder( + return ListView.separated( itemBuilder: (_, i) { return searchResults[i]; }, itemCount: searchResults.length, + separatorBuilder: (BuildContext context, int index) { + return const Divider(height: 1); + }, ); } + List _buildPage() { + if (lastNotification != null) { + return [ + Container( + padding: const EdgeInsets.all(20), + child: RichText( + text: TextSpan( + text: lastNotification?.songTitle, + style: TextStyle( + fontSize: 24.0, + color: Theme.of(context).textTheme.bodyLarge?.color, + fontWeight: FontWeight.w700, + ), + children: [ + const TextSpan( + text: '\n', + ), + TextSpan( + text: lastNotification?.artist, + style: TextStyle( + fontSize: 20.0, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ) + ]), + textAlign: TextAlign.center, + ), + ), + Container( + child: searching + ? Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: const [CircularProgressIndicator(value: null)])) + : Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + margin: const EdgeInsets.symmetric(vertical: 10), + child: Text( + running + ? 'Found ${searchResults.length} result(s)' + : "", + style: TextStyle( + fontSize: 15, + color: Theme.of(context).textTheme.bodyText1?.color, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + ), + ), + ), + Flexible(child: _buildResults()), + ], + ), + ), + ), + ]; + } + + return [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text("No supported player is running", + style: TextStyle(fontSize: 24)), + ), + ]; + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.settings), + tooltip: "Settings", + splashRadius: 20, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const SettingsPage())); + }, + ), + ], ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (running) - Container( - padding: const EdgeInsets.all(20), - child: RichText( - text: TextSpan( - text: lastNotification?.songTitle, - style: const TextStyle( - fontSize: 24.0, - color: Colors.black, - fontWeight: FontWeight.w700, - ), - children: [ - const TextSpan( - text: '\n', - ), - TextSpan( - text: lastNotification?.artist, - style: const TextStyle( - fontSize: 20.0, - color: Color(0xFF303030), - ), - ) - ]), - textAlign: TextAlign.center, - ), - ), - Container( - child: searching - ? Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: const [ - CircularProgressIndicator(value: null) - ])) - : Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10), - margin: const EdgeInsets.symmetric(vertical: 10), - child: Text( - running - ? 'Found ${searchResults.length} result(s)' - : "", - style: const TextStyle( - fontSize: 15, - color: Colors.black87, - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic, - ), - ), - ), - Flexible(child: _buildResults()), - ], - ), - ), - ), - ], + children: _buildPage(), ), ), ); diff --git a/lib/search_result.dart b/lib/search_result.dart index 96c929c..7ae04b9 100644 --- a/lib/search_result.dart +++ b/lib/search_result.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'lyrics.dart'; -class SearchResult extends StatefulWidget { +class SearchResult extends StatelessWidget { const SearchResult( {Key? key, required this.title, @@ -31,65 +30,28 @@ class SearchResult extends StatefulWidget { id = json['id'], super(key: key); - @override - State createState() => _SearchResultState(); -} - -class OptionItem extends StatelessWidget { - final String title; - final IconData icon; - final void Function()? tapFunction; - - const OptionItem( - {Key? key, required this.title, required this.icon, this.tapFunction}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: tapFunction, - child: Ink( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), - child: Row( - children: [ - Container( - margin: const EdgeInsets.only(right: 15), - child: Icon(icon), - ), - Text( - title, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - ], - ), - ), - ); - } -} - -class _SearchResultState extends State { List _buildOptions() { return [ OptionItem( title: "Open Artist Page in Genius", icon: Icons.library_music, - tapFunction: () async { - if (!await launch(widget.artistUrl)) { - throw 'could not launch ${widget.artistUrl}'; + onTap: () async { + if (!await launch(artistUrl)) { + throw 'could not launch $artistUrl'; } }), OptionItem( title: "Open Song Page in Genius", icon: Icons.music_note, - tapFunction: () async { - if (!await launch(widget.url)) { - throw 'could not launch ${widget.url}'; + onTap: () async { + if (!await launch(url)) { + throw 'could not launch $url'; } }) ]; } - void _showOptionsBottomSheet() { + void _showOptionsBottomSheet(BuildContext context) { showModalBottomSheet( context: context, builder: (context) { @@ -109,76 +71,101 @@ class _SearchResultState extends State { @override Widget build(BuildContext context) { return Material( - child: Container( - margin: const EdgeInsets.only(top: 0.5), - child: InkWell( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => LyricsPage( - title: widget.title, - artist: widget.artist, - url: widget.url, + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LyricsPage( + title: title, + artist: artist, + url: url, + ), + ), + ); + }, + onLongPress: () => _showOptionsBottomSheet(context), + child: Ink( + height: 75, + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 20), + child: Image.network( + thumbnail, + width: 75, + height: 75, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: SizedBox( + width: 75, + height: 75, + child: Icon(Icons.broken_image, size: 40)), + ); + }, ), ), - ); - }, - onLongPress: () => _showOptionsBottomSheet(), - child: Ink( - height: 75, - color: Colors.white10, - child: Row( - children: [ - Container( - margin: const EdgeInsets.only(right: 20), - child: Image.network( - widget.thumbnail, - width: 75, - height: 75, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - Fluttertoast.showToast( - msg: 'Failed to load album art for: ${widget.title}', - toastLength: Toast.LENGTH_LONG); - return const Center( - child: SizedBox( - width: 75, - height: 75, - child: Icon(Icons.broken_image, size: 40)), - ); - }, - ), - ), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.title, - style: const TextStyle( - color: Colors.black, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + style: TextStyle( + color: Theme.of(context).textTheme.bodyText1?.color, + fontSize: 18, + fontWeight: FontWeight.bold, ), - Text( - widget.artist, - style: const TextStyle( - color: Colors.black54, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, + overflow: TextOverflow.ellipsis, + ), + Text( + artist, + style: TextStyle( + color: Theme.of(context).textTheme.subtitle1?.color, + fontSize: 14, ), - ], - ), + overflow: TextOverflow.ellipsis, + ), + ], ), - ], - ), + ), + ], ), ), ), ); } } + +class OptionItem extends StatelessWidget { + final String title; + final IconData icon; + final void Function()? onTap; + + const OptionItem( + {Key? key, required this.title, required this.icon, this.onTap}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Ink( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 15), + child: Icon(icon), + ), + Text( + title, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ); + } +} diff --git a/lib/settings.dart b/lib/settings.dart new file mode 100644 index 0000000..61ab279 --- /dev/null +++ b/lib/settings.dart @@ -0,0 +1,188 @@ +// ignore_for_file: constant_identifier_names + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'theme_provider.dart'; + +enum AppTheme { + Dark, + Light, + System +} + +class Setting extends StatelessWidget { + final String title; + final String? desc; + final void Function() onTap; + + const Setting({ + Key? key, + required this.title, + this.desc, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Material( + child: InkWell( + onTap: onTap, + child: Ink( + child: Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16)), + if (desc != null) + Text(desc!, + style: const TextStyle(color: Colors.grey)), + ]), + ), + ], + ), + ), + ), + ), + ); + } +} + +class SettingsPage extends StatefulWidget { + const SettingsPage({Key? key}) : super(key: key); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final Future _prefs = SharedPreferences.getInstance(); + late SharedPreferences prefs; + String? activeTheme; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + Future initPlatformState() async { + prefs = await _prefs; + + setState(() { + activeTheme = prefs.getString("theme") ?? AppTheme.System.name; + }); + } + + Widget _buildSettingsGroup(String groupTitle, List items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(groupTitle, + style: const TextStyle(color: Colors.deepPurple)), + ), + ...items, + ], + ); + } + + List _buildSettingsList(BuildContext context) { + return [ + _buildSettingsGroup("Customization", [ + Setting( + title: "Theme", + desc: activeTheme, + onTap: () { + final provider = Provider.of(context, listen: false); + + showDialog( + context: context, + builder: (BuildContext buildCtx) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2)), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20), + child: Text("Theme", + style: Theme.of(context).textTheme.headline6), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + RadioListTile( + value: AppTheme.System.name, + groupValue: activeTheme, + title: Text("Use System Default", style: Theme.of(context).textTheme.bodyText1), + onChanged: (String? value) => setState(() { + provider.changeTheme(ThemeMode.system); + activeTheme = AppTheme.System.name; + prefs.setString("theme", AppTheme.System.name); + Navigator.pop(context); + })), + RadioListTile( + value: AppTheme.Dark.name, + groupValue: activeTheme, + title: Text("Dark", style: Theme.of(context).textTheme.bodyText1), + onChanged: (String? value) => setState(() { + provider.changeTheme(ThemeMode.dark); + activeTheme = AppTheme.Dark.name; + prefs.setString("theme", AppTheme.Dark.name); + Navigator.pop(context); + })), + RadioListTile( + value: AppTheme.Light.name, + groupValue: activeTheme, + title: Text("Light", style: Theme.of(context).textTheme.bodyText1), + onChanged: (String? value) => setState(() { + provider.changeTheme(ThemeMode.light); + activeTheme = AppTheme.Light.name; + prefs.setString("theme", AppTheme.Light.name); + Navigator.pop(context); + })), + ], + ), + ), + ], + ), + ), + ); + }); + }) + ]) + ]; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Settings"), + ), + body: ListView.separated( + itemBuilder: (_, i) { + return _buildSettingsList(context)[i]; + }, + separatorBuilder: (_, __) { + return const SizedBox(height: 15); + }, + itemCount: 1, + padding: const EdgeInsets.symmetric(vertical: 20), + ), + ); + } +} diff --git a/lib/theme_provider.dart b/lib/theme_provider.dart new file mode 100644 index 0000000..c21211a --- /dev/null +++ b/lib/theme_provider.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class ThemeProvider extends ChangeNotifier { + ThemeMode themeMode; + + ThemeProvider(this.themeMode); + + bool get isDarkMode => themeMode == ThemeMode.dark; + + void changeTheme(ThemeMode mode) { + themeMode = mode; + + notifyListeners(); + } +} + +class MyThemes { + static final darkTheme = ThemeData( + primarySwatch: Colors.deepPurple, + dialogTheme: const DialogTheme(backgroundColor: Color(0xFF202020)), + unselectedWidgetColor: Colors.white, + canvasColor: Colors.black, + bottomSheetTheme: const BottomSheetThemeData(backgroundColor: Color(0xFF202020)), + dividerColor: Colors.white, + iconTheme: const IconThemeData(color: Colors.white), + textTheme: const TextTheme( + bodyText1: TextStyle(color: Colors.white, fontSize: 16), + bodyText2: TextStyle(color: Colors.white), + subtitle1: TextStyle(color: Colors.grey), + subtitle2: TextStyle(color: Colors.white), + headline6: TextStyle(color: Colors.white), + ), + ); + + static final lightTheme = ThemeData( + primarySwatch: Colors.purple, + dividerColor: Colors.black, + textTheme: const TextTheme( + bodyText1: TextStyle(fontSize: 16), + subtitle1: TextStyle(color: Colors.black54), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index ea05e02..e41519e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -191,6 +191,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -198,6 +205,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.7" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" petitparser: dependency: transitive description: @@ -226,6 +254,69 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.4" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.15" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.12" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" sky_engine: dependency: transitive description: flutter @@ -357,6 +448,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.4" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.6.1" xdg_directories: dependency: transitive description: @@ -372,5 +470,5 @@ packages: source: hosted version: "5.3.1" sdks: - dart: ">=2.17.0-0 <3.0.0" + dart: ">=2.17.0 <3.0.0" flutter: ">=2.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 11428a8..e5af149 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.2.1+1 +version: 0.3.0+1 environment: sdk: ">=2.16.1 <3.0.0" @@ -35,6 +35,8 @@ dependencies: url_launcher: ^6.0.20 web_scraper: ^0.1.4 fluttertoast: ^8.0.9 + shared_preferences: ^2.0.15 + provider: ^6.0.3 dev_dependencies: flutter_test: