freezer/lib/ui/settings_screen.dart
Pato05 2862c9ec05
remove browser login for desktop
restore translations functionality
make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available
exit app if opening cache or settings fails (another instance running)
remove draggable_scrollbar and use builtin widget instead
fix email login
better way to manage lyrics (less updates and lookups in the lyrics List)
fix player_screen on mobile (too big -> just average :))
right click: use TapUp events instead
desktop: show context menu on triple dots button also
avoid showing connection error if the homepage is cached and available offline
i'm probably forgetting something idk
2023-10-25 00:32:28 +02:00

1876 lines
69 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math';
import 'package:country_pickers/country.dart';
import 'package:country_pickers/country_picker_dialog.dart';
import 'package:flex_color_picker/flex_color_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:fluttericon/web_symbols_icons.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/definitions.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:scrobblenaut/scrobblenaut.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/player/audio_handler.dart';
import 'package:freezer/ui/downloads_screen.dart';
import 'package:freezer/ui/elements.dart';
import 'package:freezer/ui/home_screen.dart';
import 'package:freezer/ui/updater.dart';
import 'package:freezer/translations.i18n.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/main.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Settings'.i18n)),
body: ListView(
children: <Widget>[
ListTile(
title: Text('General'.i18n),
leading:
const LeadingIcon(Icons.settings, color: Color(0xffeca704)),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const GeneralSettings()),
),
ListTile(
title: Text('Download Settings'.i18n),
leading: const LeadingIcon(Icons.cloud_download,
color: Color(0xffbe3266)),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const DownloadsSettings()),
),
ListTile(
title: Text('Appearance'.i18n),
leading:
const LeadingIcon(Icons.color_lens, color: Color(0xff4b2e7e)),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const AppearanceSettings()),
),
ListTile(
title: Text('Quality'.i18n),
leading:
const LeadingIcon(Icons.high_quality, color: Color(0xff384697)),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const QualitySettings()),
),
ListTile(
title: Text('Deezer'.i18n),
leading:
const LeadingIcon(Icons.equalizer, color: Color(0xff0880b5)),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const DeezerSettings()),
),
//Language select
ListTile(
title: Text('Language'.i18n),
leading:
const LeadingIcon(Icons.language, color: Color(0xff009a85)),
onTap: () {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text('Select language'.i18n),
children: List.generate(languages.length, (int i) {
final Language l = languages[i];
return ListTile(
title: Text(l.name),
subtitle: Text("${l.locale}-${l.country}"),
onTap: () async {
settings.language = "${l.locale}_${l.country}";
await settings.save();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Language'.i18n),
content: Text(
'Language changed, please restart Freezer to apply!'
.i18n),
actions: [
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.pop(context);
Navigator.pop(context);
},
)
],
);
});
});
})));
},
),
ListTile(
title: Text('Updates'.i18n),
leading: const LeadingIcon(Icons.update, color: Color(0xff2ba766)),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const UpdaterScreen()),
),
ListTile(
title: Text('About'.i18n),
leading: const LeadingIcon(Icons.info, color: Colors.grey),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const CreditsScreen()),
),
],
),
);
}
}
class AppearanceSettings extends StatefulWidget {
const AppearanceSettings({super.key});
@override
State<AppearanceSettings> createState() => _AppearanceSettingsState();
}
class _AppearanceSettingsState extends State<AppearanceSettings> {
ColorSwatch<dynamic> _swatch(int c) => ColorSwatch(c, {500: Color(c)});
String _navigationRailAppearanceToString(
NavigationRailAppearance navigationRailAppearance) {
switch (navigationRailAppearance) {
case NavigationRailAppearance.always_expanded:
return 'Always expanded'.i18n;
case NavigationRailAppearance.expand_on_hover:
return 'Expand on hover'.i18n;
case NavigationRailAppearance.icons_only:
return 'Icons only'.i18n;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Appearance'.i18n)),
body: ListView(
children: <Widget>[
ListTile(
title: Text('Theme'.i18n),
subtitle: Text(
'${'Currently'.i18n}: ${settings.theme.toString().split('.').lastItem}'),
leading: const Icon(Icons.color_lens),
enabled: !settings.materialYouAccent,
onTap: () {
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: Text('Select theme'.i18n),
children: <Widget>[
SimpleDialogOption(
child: Text('Light'.i18n),
onPressed: () {
settings.theme = Themes.Light;
settings.save();
updateTheme(context);
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: Text('Dark'.i18n),
onPressed: () {
settings.theme = Themes.Dark;
settings.save();
updateTheme(context);
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: Text('Black (AMOLED)'.i18n),
onPressed: () {
settings.theme = Themes.Black;
settings.save();
updateTheme(context);
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: Text('Deezer (Dark)'.i18n),
onPressed: () {
settings.theme = Themes.Deezer;
settings.save();
updateTheme(context);
Navigator.of(context).pop();
},
),
],
);
});
},
),
SwitchListTile(
title: Text('Use system theme'.i18n),
value: settings.useSystemTheme,
onChanged: settings.materialYouAccent
? null
: (bool v) async {
settings.useSystemTheme = v;
settings.save();
updateTheme(context);
},
secondary: const Icon(Icons.android)),
SwitchListTile(
value: settings.materialYouAccent,
title: Text('Use Material You accent'.i18n),
onChanged: (bool v) {
settings.materialYouAccent = v;
settings.save();
updateTheme(context);
}),
ListTile(
title: Text('Font'.i18n),
leading: const Icon(Icons.font_download),
subtitle: Text(settings.font),
onTap: () {
showDialog(
context: context,
builder: (context) =>
FontSelector(() => Navigator.of(context).pop()));
},
),
SwitchListTile(
title: Text('Player gradient background'.i18n),
secondary: const Icon(Icons.colorize),
value: settings.colorGradientBackground,
onChanged: (bool v) async {
setState(() => settings.colorGradientBackground = v);
await settings.save();
},
),
SwitchListTile(
title: Text('Player album art drop shadow'.i18n),
secondary: const Icon(Icons.opacity),
value: settings.playerAlbumArtDropShadow,
onChanged: (bool v) async {
setState(() => settings.playerAlbumArtDropShadow = v);
await settings.save();
},
),
SwitchListTile(
title: Text('Blur player background'.i18n),
subtitle: Text('Might have impact on performance'.i18n),
secondary: const Icon(Icons.blur_on),
value: settings.blurPlayerBackground,
onChanged: (bool v) async {
setState(() => settings.blurPlayerBackground = v);
await settings.save();
},
),
SwitchListTile(
title: const Text('Use player background on lyrics page'),
value: settings.playerBackgroundOnLyrics,
secondary: const Icon(Icons.wallpaper),
onChanged: settings.blurPlayerBackground ||
settings.colorGradientBackground
? (bool v) {
setState(() => settings.playerBackgroundOnLyrics = v);
settings.save();
}
: null),
ListTile(
title: const Text('Screens style'),
subtitle: const Text(
'Style of the transition between screens within the app'),
leading: const Icon(Icons.auto_awesome_motion),
onTap: () => showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text('Select screens style'),
children: <Widget>[
SimpleDialogOption(
child: const Text('Blur slide (might be laggy!)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.blur_slide;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: const Text('Fade'),
onPressed: () {
settings.navigatorRouteType = NavigatorRouteType.fade;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: const Text('Fade with blur (might be laggy!)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.fade_blur;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: const Text('Material (default)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.material;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: const Text('Cupertino (iOS)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.cupertino;
settings.save();
Navigator.of(context).pop();
},
),
],
);
}),
),
SwitchListTile(
title: const Text('Enable filled play button'),
secondary: const Icon(Icons.play_circle),
value: settings.enableFilledPlayButton,
onChanged: (bool v) {
setState(() => settings.enableFilledPlayButton = v);
settings.save();
}),
SwitchListTile(
title: Text('Visualizer'.i18n),
subtitle: Text(
'Show visualizers on lyrics page. WARNING: Requires microphone permission!'
.i18n),
secondary: const Icon(Icons.equalizer),
value: settings.lyricsVisualizer,
onChanged: null, // TODO: visualizer
//(bool v) async {
// if (await Permission.microphone.request().isGranted) {
// setState(() => settings.lyricsVisualizer = v);
// await settings.save();
// return;
// }
//},
),
ListTile(
title: Text('Primary color'.i18n),
leading: const Icon(Icons.format_paint),
trailing: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: CircleAvatar(
backgroundColor: settings.primaryColor,
)),
enabled: !settings.materialYouAccent,
onTap: () async {
final color = await showDialog<Color>(
context: context, builder: (context) => const _ColorPicker());
if (color == null) return;
settings.primaryColor = color;
settings.save();
updateTheme(context);
//showDialog(
// context: context,
// builder: (context) {
// return AlertDialog(
// title: Text('Primary color'.i18n),
// content: SizedBox(
// height: 240,
// child: MaterialColorPicker(
// colors: [
// ...Colors.primaries,
// //Logo colors
// _swatch(0xffeca704),
// _swatch(0xffbe3266),
// _swatch(0xff4b2e7e),
// _swatch(0xff384697),
// _swatch(0xff0880b5),
// _swatch(0xff009a85),
// _swatch(0xff2ba766)
// ],
// allowShades: false,
// selectedColor: settings.primaryColor,
// onMainColorChange: (ColorSwatch? color) {
// if (color == null) return;
// settings.primaryColor = color;
// settings.save();
// updateTheme(context);
// Navigator.of(context).pop();
// },
// ),
// ),
// );
// });
},
),
SwitchListTile(
title: Text('Use album art primary color'.i18n),
subtitle: Text('Warning: might be buggy'.i18n),
secondary: const Icon(Icons.invert_colors),
value: settings.useArtColor,
onChanged: (v) => setState(() => settings.updateUseArtColor(v)),
),
if (MainScreen.of(context).isDesktop)
ListTile(
leading: const Icon(Icons.view_sidebar),
title: Text('Navigation rail appearance'.i18n),
subtitle: Text(
'${'Currently'.i18n}: ${_navigationRailAppearanceToString(settings.navigationRailAppearance)}'),
onTap: () => showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text('Navigation rail appearance'.i18n),
children: NavigationRailAppearance.values
.map((value) => SimpleDialogOption(
child: Text(
_navigationRailAppearanceToString(value)),
onPressed: () {
settings.navigationRailAppearance = value;
Navigator.pop(context);
settings
.save()
.then((_) => updateTheme(context));
}))
.toList(growable: false),
)),
),
//Display mode (Android only!)
if (defaultTargetPlatform == TargetPlatform.android)
ListTile(
leading: const Icon(Icons.screen_lock_portrait),
title: Text('Change display mode'.i18n),
subtitle: Text('Enable high refresh rates'.i18n),
onTap: () async {
final modes = await FlutterDisplayMode.supported;
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: Text('Display mode'.i18n),
children: List.generate(
modes.length,
(i) => SimpleDialogOption(
child: Text(modes[i].toString()),
onPressed: () async {
final navigator = Navigator.of(context);
settings.displayMode = i;
await settings.save();
await FlutterDisplayMode.setPreferredMode(
modes[i]);
navigator.pop();
},
)));
});
},
)
],
),
);
}
}
class _ColorPicker extends StatefulWidget {
const _ColorPicker({super.key});
@override
State<_ColorPicker> createState() => _ColorPickerState();
}
class _ColorPickerState extends State<_ColorPicker> {
Color color = settings.primaryColor;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Primary color'.i18n),
content: SizedBox(
height: 600.0,
width: min(MediaQuery.of(context).size.width * 0.9, 600.0),
child: SingleChildScrollView(
child: ColorPicker(
width: 56.0,
height: 56.0,
borderRadius: 50.0,
onColorChanged: (color) => setState(() => this.color = color),
color: color,
showColorCode: true,
pickersEnabled: const {
ColorPickerType.both: false,
ColorPickerType.primary: true,
ColorPickerType.accent: false,
ColorPickerType.bw: false,
ColorPickerType.custom: true,
ColorPickerType.wheel: true,
},
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, color),
child: Text('OK'.i18n)),
],
);
}
}
class FontSelector extends StatefulWidget {
final Function callback;
const FontSelector(this.callback, {Key? key}) : super(key: key);
@override
State<FontSelector> createState() => _FontSelectorState();
}
class _FontSelectorState extends State<FontSelector> {
String query = '';
List<String> get fonts {
return settings.fonts
.where((f) => f.toLowerCase().contains(query))
.toList();
}
//Font selected
void onTap(String font) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Warning'.i18n),
content: Text(
"This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!"
.i18n),
actions: [
TextButton(
onPressed: () async {
setState(() => settings.font = font);
await settings.save();
Navigator.of(context).pop();
widget.callback();
//Global setState
updateTheme(context);
},
child: Text('Apply'.i18n),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
widget.callback();
},
child: const Text('Cancel'),
)
],
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: false,
title: Text("Select font".i18n),
content: Column(mainAxisSize: MainAxisSize.min, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Search'.i18n,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder()),
onChanged: (q) => setState(() => query = q),
),
),
SizedBox(
height: MediaQuery.of(context).size.height - 300.0,
width: 400.0,
child: Material(
type: MaterialType.transparency,
child: ListView.builder(
shrinkWrap: true,
itemExtent: 56.0,
itemCount: fonts.length,
itemBuilder: (context, index) => ListTile(
title: Text(fonts[index]),
onTap: () => onTap(fonts[index]))),
),
),
]),
);
}
}
class QualitySettings extends StatefulWidget {
const QualitySettings({super.key});
@override
State<QualitySettings> createState() => _QualitySettingsState();
}
class _QualitySettingsState extends State<QualitySettings> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Quality'.i18n)),
body: ListView(
children: <Widget>[
...(playerHelper.isConnectivityPluginAvailable
? [
ListTile(
title: Text('Mobile streaming'.i18n),
leading: const LeadingIcon(Icons.network_cell,
color: Color(0xff384697)),
),
const QualityPicker('mobile'),
const FreezerDivider(),
ListTile(
title: Text('Wifi streaming'.i18n),
leading: const LeadingIcon(Icons.network_wifi,
color: Color(0xff0880b5)),
),
const QualityPicker('wifi'),
]
: [
ListTile(
title: Text('Streaming'.i18n),
leading: const LeadingIcon(Icons.cloud,
color: Color(0xff384697))),
const QualityPicker('mobile_wifi'),
]),
const FreezerDivider(),
ListTile(
title: Text('Offline'.i18n),
leading:
const LeadingIcon(Icons.offline_pin, color: Color(0xff009a85)),
),
const QualityPicker('offline'),
const FreezerDivider(),
ListTile(
title: Text('External downloads'.i18n),
leading: const LeadingIcon(Icons.file_download,
color: Color(0xff2ba766)),
),
const QualityPicker('download'),
],
),
);
}
}
class QualityPicker extends StatefulWidget {
final String field;
const QualityPicker(this.field, {Key? key}) : super(key: key);
@override
State<QualityPicker> createState() => _QualityPickerState();
}
class _QualityPickerState extends State<QualityPicker> {
late AudioQuality _quality;
bool flacDisabled = !cache.canStreamLossless;
bool hqDisabled = !cache.canStreamHQ;
@override
void initState() {
_getQuality();
super.initState();
}
//Get current quality
void _getQuality() {
switch (widget.field) {
case 'mobile_wifi':
case 'mobile':
_quality = settings.mobileQuality;
break;
case 'wifi':
_quality = settings.wifiQuality;
break;
case 'download':
_quality = settings.downloadQuality;
break;
case 'offline':
_quality = settings.offlineQuality;
break;
}
}
//Update quality in settings
void _updateQuality(AudioQuality? q) async {
if (q == null) return;
setState(() {
_quality = q;
});
switch (widget.field) {
case 'mobile_wifi':
settings.mobileQuality = settings.wifiQuality = _quality;
settings.updateAudioServiceQuality();
break;
case 'mobile':
settings.mobileQuality = _quality;
settings.updateAudioServiceQuality();
break;
case 'wifi':
settings.wifiQuality = _quality;
settings.updateAudioServiceQuality();
break;
case 'download':
settings.downloadQuality = _quality;
break;
case 'offline':
settings.offlineQuality = _quality;
break;
}
await settings.save();
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
RadioListTile(
title: const Text('MP3 128kbps'),
groupValue: _quality,
value: AudioQuality.MP3_128,
onChanged: (AudioQuality? q) => _updateQuality(q),
),
RadioListTile(
title: const Text('MP3 320kbps'),
groupValue: _quality,
value: AudioQuality.MP3_320,
onChanged: hqDisabled ? null : (AudioQuality? q) => _updateQuality(q),
),
RadioListTile(
title: const Text('FLAC'),
groupValue: _quality,
value: AudioQuality.FLAC,
onChanged:
flacDisabled ? null : (AudioQuality? q) => _updateQuality(q),
),
if (widget.field == 'download')
RadioListTile(
title: Text('Ask before downloading'.i18n),
groupValue: _quality,
value: AudioQuality.ASK,
onChanged: (AudioQuality? q) => _updateQuality(q),
)
],
);
}
}
class ContentLanguage {
String code;
String name;
ContentLanguage(this.code, this.name);
static List<ContentLanguage> get all => [
ContentLanguage("cs", "Čeština"),
ContentLanguage("da", "Dansk"),
ContentLanguage("de", "Deutsch"),
ContentLanguage("en", "English"),
ContentLanguage("us", "English (us)"),
ContentLanguage("es", "Español"),
ContentLanguage("mx", "Español (latam)"),
ContentLanguage("fr", "Français"),
ContentLanguage("hr", "Hrvatski"),
ContentLanguage("id", "Indonesia"),
ContentLanguage("it", "Italiano"),
ContentLanguage("hu", "Magyar"),
ContentLanguage("ms", "Melayu"),
ContentLanguage("nl", "Nederlands"),
ContentLanguage("no", "Norsk"),
ContentLanguage("pl", "Polski"),
ContentLanguage("br", "Português (br)"),
ContentLanguage("pt", "Português (pt)"),
ContentLanguage("ro", "Română"),
ContentLanguage("sk", "Slovenčina"),
ContentLanguage("sl", "Slovenščina"),
ContentLanguage("sq", "Shqip"),
ContentLanguage("sr", "Srpski"),
ContentLanguage("fi", "Suomi"),
ContentLanguage("sv", "Svenska"),
ContentLanguage("tr", "Türkçe"),
ContentLanguage("bg", "Български"),
ContentLanguage("ru", "Pусский"),
ContentLanguage("uk", "Українська"),
ContentLanguage("he", "עִברִית"),
ContentLanguage("ar", "العربیة"),
ContentLanguage("cn", "中文"),
ContentLanguage("ja", "日本語"),
ContentLanguage("ko", "한국어"),
ContentLanguage("th", "ภาษาไทย"),
];
}
class DeezerSettings extends StatefulWidget {
const DeezerSettings({super.key});
@override
State<DeezerSettings> createState() => _DeezerSettingsState();
}
class _DeezerSettingsState extends State<DeezerSettings> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Deezer'.i18n)),
body: ListView(
children: <Widget>[
ListTile(
title: Text('Content language'.i18n),
subtitle: Text(
'${'Not app language, used in headers. Now'.i18n}: ${settings.deezerLanguage}'),
leading: const Icon(Icons.language),
onTap: () {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text('Select language'.i18n),
children: List.generate(
ContentLanguage.all.length,
(i) => ListTile(
title: Text(ContentLanguage.all[i].name),
subtitle: Text(ContentLanguage.all[i].code),
onTap: () async {
setState(() => settings.deezerLanguage =
ContentLanguage.all[i].code);
await settings.save();
deezerAPI.updateHeaders();
Navigator.of(context).pop();
},
)),
));
},
),
ListTile(
title: Text('Content country'.i18n),
subtitle: Text(
'${'Country used in headers. Now'.i18n}: ${settings.deezerCountry}'),
leading: const Icon(Icons.vpn_lock),
onTap: () {
showDialog(
context: context,
builder: (context) => CountryPickerDialog(
titlePadding: const EdgeInsets.all(8.0),
isSearchable: true,
onValuePicked: (Country country) {
setState(
() => settings.deezerCountry = country.isoCode);
deezerAPI.updateHeaders();
settings.save();
},
));
},
),
SwitchListTile(
title: Text('Log tracks'.i18n),
subtitle: Text(
'Send track listen logs to Deezer, enable it for features like Flow to work properly'
.i18n),
value: settings.logListen,
secondary: const Icon(Icons.history_toggle_off),
onChanged: (bool v) {
setState(() => settings.logListen = v);
settings.save();
},
),
//TODO: Reimplement proxy
// ListTile(
// title: Text('Proxy'.i18n),
// leading: Icon(Icons.vpn_key),
// subtitle: Text(settings.proxyAddress??'Not set'.i18n),
// onTap: () {
// String _new;
// showDialog(
// context: context,
// builder: (BuildContext context) {
// return AlertDialog(
// title: Text('Proxy'.i18n),
// content: TextField(
// onChanged: (String v) => _new = v,
// decoration: InputDecoration(
// hintText: 'IP:PORT'
// ),
// ),
// actions: [
// TextButton(
// child: Text('Cancel'.i18n),
// onPressed: () => Navigator.of(context).pop(),
// ),
// TextButton(
// child: Text('Reset'.i18n),
// onPressed: () async {
// setState(() {
// settings.proxyAddress = null;
// });
// await settings.save();
// Navigator.of(context).pop();
// },
// ),
// TextButton(
// child: Text('Save'.i18n),
// onPressed: () async {
// setState(() {
// settings.proxyAddress = _new;
// });
// await settings.save();
// Navigator.of(context).pop();
// },
// )
// ],
// );
// }
// );
// },
// )
],
),
);
}
}
class FilenameTemplateDialog extends StatefulWidget {
final String? initial;
final Function onSave;
const FilenameTemplateDialog(this.initial, this.onSave, {Key? key})
: super(key: key);
@override
State<FilenameTemplateDialog> createState() => _FilenameTemplateDialogState();
}
class _FilenameTemplateDialogState extends State<FilenameTemplateDialog> {
TextEditingController? _controller;
String? _new;
@override
void initState() {
_controller = TextEditingController(text: widget.initial);
_new = _controller!.value.text;
super.initState();
}
@override
Widget build(BuildContext context) {
//Dialog with filename format
return AlertDialog(
title: Text('Downloaded tracks filename'.i18n),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _controller,
onChanged: (String s) => _new = s,
),
Container(height: 8.0),
Text(
'${'Valid variables are'.i18n}: %artists%, %artist%, %title%, %album%, %trackNumber%, %0trackNumber%, %feats%, %playlistTrackNumber%, %0playlistTrackNumber%, %year%, %date%\n\n${"If you want to use custom directory naming - use '/' as directory separator.".i18n}',
style: const TextStyle(
fontSize: 12.0,
),
)
],
),
actions: [
TextButton(
child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text('Reset'.i18n),
onPressed: () {
_controller!.value =
_controller!.value.copyWith(text: '%artist% - %title%');
_new = '%artist% - %title%';
},
),
TextButton(
child: Text('Clear'.i18n),
onPressed: () => _controller!.clear(),
),
TextButton(
child: Text('Save'.i18n),
onPressed: () async {
widget.onSave(_new);
Navigator.of(context).pop();
},
)
],
);
}
}
class DownloadsSettings extends StatefulWidget {
const DownloadsSettings({super.key});
@override
State<DownloadsSettings> createState() => _DownloadsSettingsState();
}
class _DownloadsSettingsState extends State<DownloadsSettings> {
double _downloadThreads = settings.downloadThreads.toDouble();
final TextEditingController _artistSeparatorController =
TextEditingController(text: settings.artistSeparator);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Download Settings'.i18n)),
body: ListView(
children: [
ListTile(
title: Text('Download path'.i18n),
leading: const Icon(Icons.folder),
subtitle: Text(settings.downloadPath!),
onTap: () async {
//Check permissions
if (!await Permission.storage.request().isGranted) return;
DownloadManager.getDirectory('Pick-a-Path'.i18n).then((path) {
if (path == null) return; // user canceled
setState(() => settings.downloadPath = path);
settings.save();
});
//Navigate
// Navigator.of(context).pushRoute(
// builder: (context) => DirectoryPicker(
// settings.downloadPath,
// onSelect: (String p) async {
// setState(() => settings.downloadPath = p);
// await settings.save();
// },
// ));
},
),
ListTile(
title: Text('Downloads naming'.i18n),
subtitle: Text('${'Currently'.i18n}: ${settings.downloadFilename}'),
leading: const Icon(Icons.text_format),
onTap: () {
showDialog(
context: context,
builder: (context) {
return FilenameTemplateDialog(settings.downloadFilename,
(f) async {
setState(() => settings.downloadFilename = f);
await settings.save();
});
});
},
),
ListTile(
title: Text('Singleton naming'.i18n),
subtitle:
Text('${'Currently'.i18n}: ${settings.singletonFilename}'),
leading: const Icon(Icons.text_format),
onTap: () {
showDialog(
context: context,
builder: (context) {
return FilenameTemplateDialog(settings.singletonFilename,
(f) async {
setState(() => settings.singletonFilename = f);
await settings.save();
});
});
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'${'Download threads'.i18n}: ${_downloadThreads.round().toString()}',
style: const TextStyle(fontSize: 16.0),
),
),
Slider(
min: 1,
max: 16,
divisions: 15,
value: _downloadThreads,
label: _downloadThreads.round().toString(),
onChanged: (double v) => setState(() => _downloadThreads = v),
onChangeEnd: (double val) async {
_downloadThreads = val;
setState(() {
settings.downloadThreads = _downloadThreads.round();
_downloadThreads = settings.downloadThreads.toDouble();
});
await settings.save();
//Prevent null
if (val > 8 && cache.threadsWarning != true) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Warning'.i18n),
content: Text(
'Using too many concurrent downloads on older/weaker devices might cause crashes!'
.i18n),
actions: [
TextButton(
child: Text('Dismiss'.i18n),
onPressed: () => Navigator.of(context).pop(),
)
],
);
});
cache.threadsWarning = true;
await cache.save();
}
}),
const FreezerDivider(),
ListTile(
title: Text('Tags'.i18n),
leading: const Icon(Icons.label),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const TagSelectionScreen()),
),
SwitchListTile(
title: Text('Create folders for artist'.i18n),
value: settings.artistFolder,
onChanged: (v) {
setState(() => settings.artistFolder = v);
settings.save();
},
secondary: const Icon(Icons.folder),
),
SwitchListTile(
title: Text('Create folders for albums'.i18n),
value: settings.albumFolder,
onChanged: (v) {
setState(() => settings.albumFolder = v);
settings.save();
},
secondary: const Icon(Icons.folder)),
SwitchListTile(
title: Text('Create folder for playlist'.i18n),
value: settings.playlistFolder,
onChanged: (v) {
setState(() => settings.playlistFolder = v);
settings.save();
},
secondary: const Icon(Icons.folder)),
const FreezerDivider(),
SwitchListTile(
title: Text('Separate albums by discs'.i18n),
value: settings.albumDiscFolder,
onChanged: (v) {
setState(() => settings.albumDiscFolder = v);
settings.save();
},
secondary: const Icon(Icons.album)),
SwitchListTile(
title: Text('Overwrite already downloaded files'.i18n),
value: settings.overwriteDownload,
onChanged: (v) {
setState(() => settings.overwriteDownload = v);
settings.save();
},
secondary: const Icon(Icons.delete)),
SwitchListTile(
title: Text('Download .LRC lyrics'.i18n),
value: settings.downloadLyrics,
onChanged: (v) {
setState(() => settings.downloadLyrics = v);
settings.save();
},
secondary: const Icon(Icons.subtitles)),
const FreezerDivider(),
SwitchListTile(
title: Text('Save cover file for every track'.i18n),
value: settings.trackCover,
onChanged: (v) {
setState(() => settings.trackCover = v);
settings.save();
},
secondary: const Icon(Icons.image)),
SwitchListTile(
title: Text('Save album cover'.i18n),
value: settings.albumCover,
onChanged: (v) {
setState(() => settings.albumCover = v);
settings.save();
},
secondary: const Icon(Icons.image)),
ListTile(
title: Text('Album cover resolution'.i18n),
subtitle: Text(
"WARNING: Resolutions above 1200 aren't officially supported"
.i18n),
leading: const Icon(Icons.image),
trailing: SizedBox(
width: 75.0,
child: DropdownButton<int>(
value: settings.albumArtResolution,
items: [400, 800, 1000, 1200, 1400, 1600, 1800]
.map<DropdownMenuItem<int>>(
(int i) => DropdownMenuItem<int>(
value: i,
child: Text(i.toString()),
))
.toList(),
onChanged: (int? n) async {
if (n == null) return;
setState(() {
settings.albumArtResolution = n;
});
await settings.save();
},
))),
SwitchListTile(
title: Text('Create .nomedia files'.i18n),
subtitle:
Text('To prevent gallery being filled with album art'.i18n),
value: settings.nomediaFiles,
onChanged: (v) {
setState(() => settings.nomediaFiles = v);
settings.save();
},
secondary: const Icon(Icons.insert_drive_file)),
ListTile(
title: Text('Artist separator'.i18n),
leading: const Icon(WebSymbols.tag),
trailing: SizedBox(
width: 75.0,
child: TextField(
controller: _artistSeparatorController,
onChanged: (s) async {
settings.artistSeparator = s;
await settings.save();
},
),
),
),
const FreezerDivider(),
ListTile(
title: Text('Download Log'.i18n),
leading: const Icon(Icons.sticky_note_2),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => const DownloadLogViewer()),
)
],
),
);
}
}
class TagOption {
String title;
String value;
TagOption(this.title, this.value);
}
class TagSelectionScreen extends StatefulWidget {
const TagSelectionScreen({super.key});
@override
State<TagSelectionScreen> createState() => _TagSelectionScreenState();
}
class _TagSelectionScreenState extends State<TagSelectionScreen> {
List<TagOption> tags = [
TagOption("Title".i18n, 'title'),
TagOption("Album".i18n, 'album'),
TagOption('Artist'.i18n, 'artist'),
TagOption('Track number'.i18n, 'track'),
TagOption('Disc number'.i18n, 'disc'),
TagOption('Album artist'.i18n, 'albumArtist'),
TagOption('Date/Year'.i18n, 'date'),
TagOption('Label'.i18n, 'label'),
TagOption('ISRC'.i18n, 'isrc'),
TagOption('UPC'.i18n, 'upc'),
TagOption('Track total'.i18n, 'trackTotal'),
TagOption('BPM'.i18n, 'bpm'),
TagOption('Unsynchronized lyrics'.i18n, 'lyrics'),
TagOption('Genre'.i18n, 'genre'),
TagOption('Contributors'.i18n, 'contributors'),
TagOption('Album art'.i18n, 'art')
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Tags'.i18n)),
body: ListView(
children: List.generate(
tags.length,
(i) => ListTile(
title: Text(tags[i].title),
leading: Switch(
value: settings.tags.contains(tags[i].value),
onChanged: (v) async {
//Update
if (v) {
settings.tags.add(tags[i].value);
} else {
settings.tags.remove(tags[i].value);
}
setState(() {});
await settings.save();
},
),
)),
),
);
}
}
class GeneralSettings extends StatefulWidget {
const GeneralSettings({super.key});
@override
State<GeneralSettings> createState() => _GeneralSettingsState();
}
class _GeneralSettingsState extends State<GeneralSettings> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('General'.i18n)),
body: ListView(
children: <Widget>[
SwitchListTile(
title: Text('Offline mode'.i18n),
subtitle: Text('Will be overridden on start.'.i18n),
value: settings.offlineMode,
secondary: const Icon(Icons.lock),
onChanged: (bool v) {
if (v) {
setState(() => settings.offlineMode = true);
return;
}
showDialog(
context: context,
builder: (context) {
deezerAPI.authorize().then((v) {
if (v) {
setState(() => settings.offlineMode = false);
} else {
Fluttertoast.showToast(
msg:
'Error logging in, check your internet connections.'
.i18n,
gravity: ToastGravity.BOTTOM,
toastLength: Toast.LENGTH_SHORT);
}
Navigator.of(context).pop();
});
return AlertDialog(
title: Text('Logging in...'.i18n),
content:
const Center(child: CircularProgressIndicator()));
});
},
),
SwitchListTile(
title: Text('Enable equalizer'.i18n),
subtitle: Text(
'Might enable some equalizer apps to work. Requires restart of Freezer'
.i18n),
secondary: const Icon(Icons.equalizer),
value: settings.enableEqualizer,
onChanged: (v) async {
setState(() => settings.enableEqualizer = v);
settings.save();
},
),
SwitchListTile(
title: Text('Ignore interruptions'.i18n),
subtitle: Text('Requires app restart to apply!'.i18n),
secondary: const Icon(Icons.not_interested),
value: settings.ignoreInterruptions,
onChanged: (bool v) async {
setState(() => settings.ignoreInterruptions = v);
await settings.save();
},
),
SwitchListTile(
title: Text('Use seek buttons as skip'.i18n),
subtitle: Text('May be useful for Android TV. '.i18n +
'Requires app restart to apply!'.i18n),
secondary: const Icon(Icons.fast_forward),
value: settings.seekAsSkip,
onChanged: (bool v) async {
setState(() => settings.seekAsSkip = v);
await settings.save();
},
),
ListTile(
title: Text('LastFM'.i18n),
subtitle: Text((settings.lastFMPassword != null &&
settings.lastFMUsername != null)
? 'Log out'.i18n
: 'Login to enable scrobbling.'.i18n),
leading: const Icon(FontAwesome5.lastfm),
onTap: () async {
//Log out
if (settings.lastFMPassword != null &&
settings.lastFMUsername != null) {
settings.lastFMUsername = null;
settings.lastFMPassword = null;
await settings.save();
await audioHandler.customAction("disableLastFM", {});
setState(() {});
Fluttertoast.showToast(msg: 'Logged out!'.i18n);
return;
}
await showDialog(
context: context, builder: (context) => const LastFMLogin());
setState(() {});
},
),
ListTile(
title: Text(
'Log out'.i18n,
style: const TextStyle(color: Colors.red),
),
leading: const Icon(Icons.exit_to_app),
onTap: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Log out'.i18n),
// content: Text('Due to plugin incompatibility, login using browser is unavailable without restart.'.i18n),
content: Text(
'Restart of app is required to properly log out!'
.i18n),
actions: <Widget>[
TextButton(
child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(),
),
// TextButton(
// child: Text('(ARL ONLY) Continue'.i18n),
// onPressed: () async {
// await logOut();
// Navigator.of(context).pop();
// },
// ),
TextButton(
child: Text('Log out & Exit'.i18n),
onPressed: () async {
try {
await audioHandler.stop();
await DownloadManager.platform
.invokeMethod("kill");
} catch (e) {}
await logOut();
SystemNavigator.pop();
},
)
],
);
});
}),
ListTile(
title: Text('Copy ARL'.i18n),
subtitle:
Text('Copy userToken/ARL Cookie for use in other apps.'.i18n),
leading: const Icon(Icons.lock),
onTap: () async {
if (settings.arl == null) return;
Clipboard.setData(ClipboardData(text: settings.arl!));
ScaffoldMessenger.of(context).snack('Copied'.i18n);
},
),
],
),
);
}
}
class LastFMLogin extends StatefulWidget {
const LastFMLogin({super.key});
@override
State<LastFMLogin> createState() => _LastFMLoginState();
}
class _LastFMLoginState extends State<LastFMLogin> {
String _username = '';
String _password = '';
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Login to LastFM'.i18n),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: InputDecoration(labelText: 'Username'.i18n),
onChanged: (v) => _username = v,
),
Container(height: 8.0),
TextField(
obscureText: true,
decoration: InputDecoration(labelText: 'Password'.i18n),
onChanged: (v) => _password = v,
)
],
),
actions: [
TextButton(
child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text('Login'.i18n),
onPressed: () async {
LastFM last;
try {
last = await LastFM.authenticate(
apiKey: 'b6ab5ae967bcd8b10b23f68f42493829',
apiSecret: '861b0dff9a8a574bec747f9dab8b82bf',
username: _username,
password: _password);
} catch (e) {
Fluttertoast.showToast(msg: 'Authorization error!'.i18n);
return;
}
//Save
settings.lastFMUsername = last.username;
settings.lastFMPassword = last.passwordHash;
await settings.save();
await playerHelper.authorizeLastFM();
Navigator.of(context).pop();
},
),
],
);
}
}
// class DirectoryPicker extends StatefulWidget {
// final String initialPath;
// final Function onSelect;
// DirectoryPicker(this.initialPath, {this.onSelect, Key key}) : super(key: key);
// @override
// State<DirectoryPicker> createState() => _DirectoryPickerState();
// }
// class _DirectoryPickerState extends State<DirectoryPicker> {
// String _path;
// String _previous;
// String _root;
// @override
// void initState() {
// _path = widget.initialPath;
// super.initState();
// }
// Future _resetPath() async {
// StorageInfo si = (await PathProviderEx.getStorageInfo())[0];
// setState(() => _path = si.appFilesDir);
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// appBar: AppBar(
// 'Pick-a-Path'.i18n,
// actions: <Widget>[
// IconButton(
// icon: Icon(
// Icons.sd_card,
// semanticLabel: 'Select storage'.i18n,
// ),
// onPressed: () {
// String path = '';
// //Chose storage
// showDialog(
// context: context,
// builder: (context) {
// return AlertDialog(
// title: Text('Select storage'.i18n),
// content: FutureBuilder(
// future: PathProviderEx.getStorageInfo(),
// builder: (context, snapshot) {
// if (snapshot.hasError) return ErrorScreen();
// if (!snapshot.hasData)
// return Padding(
// padding: EdgeInsets.symmetric(vertical: 8.0),
// child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: <Widget>[
// CircularProgressIndicator()
// ],
// ),
// );
// return Column(
// mainAxisSize: MainAxisSize.min,
// children: List<Widget>.generate(
// snapshot.data.length, (int i) {
// StorageInfo si = snapshot.data[i];
// return ListTile(
// title: Text(si.rootDir),
// leading: Icon(Icons.sd_card),
// trailing: Text(filesize(si.availableBytes)),
// onTap: () {
// setState(() {
// _path = si.appFilesDir;
// //Android 5+ blocks sd card, so this prevents going outside
// //app data dir, until permission request fix.
// _root = si.rootDir;
// if (i != 0) _root = si.appFilesDir;
// });
// Navigator.of(context).pop();
// },
// );
// }));
// },
// ),
// );
// });
// })
// ],
// ),
// floatingActionButton: FloatingActionButton(
// child: Icon(Icons.done),
// onPressed: () {
// //When folder confirmed
// if (widget.onSelect != null) widget.onSelect(_path);
// Navigator.of(context).pop();
// },
// ),
// body: FutureBuilder(
// future: Directory(_path).list().toList(),
// builder: (BuildContext context, AsyncSnapshot snapshot) {
// //On error go to last good path
// if (snapshot.hasError)
// Future.delayed(Duration(milliseconds: 50), () {
// if (_previous == null) {
// _resetPath();
// return;
// }
// setState(() => _path = _previous);
// });
// if (!snapshot.hasData)
// return Center(
// child: CircularProgressIndicator(),
// );
// List<FileSystemEntity> data = snapshot.data;
// return ListView(
// children: <Widget>[
// ListTile(
// title: Text(_path),
// ),
// ListTile(
// title: Text('Go up'.i18n),
// leading: Icon(Icons.arrow_upward),
// onTap: () {
// setState(() {
// if (_root == _path) {
// Fluttertoast.showToast(
// msg: 'Permission denied'.i18n,
// gravity: ToastGravity.BOTTOM);
// return;
// }
// _previous = _path;
// _path = Directory(_path).parent.path;
// });
// },
// ),
// ...List.generate(data.length, (i) {
// FileSystemEntity f = data[i];
// if (f is Directory) {
// return ListTile(
// title: Text(f.path.split('/').last),
// leading: Icon(Icons.folder),
// onTap: () {
// setState(() {
// _previous = _path;
// _path = f.path;
// });
// },
// );
// }
// return Container(
// height: 0,
// width: 0,
// );
// })
// ],
// );
// },
// ),
// );
// }
// }
class CreditsScreen extends StatefulWidget {
const CreditsScreen({super.key});
@override
State<CreditsScreen> createState() => _CreditsScreenState();
}
class _CreditsScreenState extends State<CreditsScreen> {
String _version = '';
static final List<List<String>> translators = [
['Xandar Null', 'Arabic'],
['Markus', 'German'],
['Andrea', 'Italian'],
['Diego Hiro', 'Portuguese'],
['Orfej', 'Russian'],
['Chino Pacia', 'Filipino'],
['ArcherDelta & PetFix', 'Spanish'],
['Shazzaam', 'Croatian'],
['VIRGIN_KLM', 'Greek'],
['koreezzz', 'Korean'],
['Fwwwwwwwwwweze', 'French'],
['kobyrevah', 'Hebrew'],
['HoScHaKaL', 'Turkish'],
['MicroMihai', 'Romanian'],
['LenteraMalam', 'Indonesian'],
['RTWO2', 'Persian']
];
@override
void initState() {
PackageInfo.fromPlatform().then((info) {
setState(() {
_version = 'v${info.version}';
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('About'.i18n)),
body: ListView(
children: [
const FreezerTitle(),
Text(
_version,
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
const FreezerDivider(),
ListTile(
title: Text('Telegram Channel'.i18n),
subtitle: Text('To get latest releases'.i18n),
leading: const Icon(FontAwesome5.telegram,
color: Color(0xFF27A2DF), size: 36.0),
onTap: () {
launchUrl(Uri.parse('https://t.me/joinchat/Se4zLEBvjS1NCiY9'));
},
),
ListTile(
title: Text('Telegram Group'.i18n),
subtitle: Text('Official chat'.i18n),
leading: const Icon(FontAwesome5.telegram,
color: Colors.cyan, size: 36.0),
onTap: () => launchUrl(Uri.parse('https://t.me/freezerandroid')),
),
ListTile(
title: Text('Discord'.i18n),
subtitle: Text('Official Discord server'.i18n),
leading: const Icon(FontAwesome5.discord,
color: Color(0xff7289da), size: 36.0),
onTap: () => launchUrl(Uri.parse('https://discord.gg/qwJpa3r4dQ')),
),
ListTile(
title: Text('${'Repository'.i18n} (unavailable)'),
subtitle: Text('Source code, report issues there.'.i18n),
leading: const Icon(Icons.code, color: Colors.green, size: 36.0),
enabled: false,
),
const ListTile(
enabled: false,
title: Text('Don\'t Donate'),
subtitle: Text(
'You should rather support your favorite artists, instead of this app!'),
leading: Icon(FontAwesome5.paypal, color: Colors.blue, size: 36.0),
),
const FreezerDivider(),
const ListTile(
title: Text('Pato05'),
subtitle: Text('Current Developer - best of all'),
),
const ListTile(
title: Text('exttex'),
subtitle: Text('Ex-Developer'),
),
const ListTile(
title: Text('Bas Curtiz'),
subtitle: Text('Icon, logo, banner, design suggestions, tester'),
),
const ListTile(
title: Text('Tobs'),
subtitle: Text('Alpha testers'),
),
const ListTile(
title: Text('Deemix'),
subtitle: Text('Better app <3'),
),
const ListTile(
title: Text('Xandar Null'),
subtitle: Text('Tester, translations help'),
),
ListTile(
title: const Text('Francesco'),
subtitle: const Text('Tester'),
onTap: () {
setState(() {
settings.primaryColor = const Color(0xff333333);
});
updateTheme(context);
settings.save();
},
),
const ListTile(
title: Text('Annexhack'),
subtitle: Text('Android Auto help'),
),
const FreezerDivider(),
...List.generate(
translators.length,
(i) => ListTile(
title: Text(translators[i][0]),
subtitle: Text(translators[i][1]),
)),
Padding(
padding: const EdgeInsets.fromLTRB(0, 4, 0, 8),
child: Text(
'Huge thanks to all the contributors! <3'.i18n,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16.0),
),
)
],
),
);
}
}