[Release] v4.2.0+40200006 (#296)

TWEAKS
- [Sonarr/Upcoming] If an episode has not aired, show it as "Upcoming" instead of "Not Downloaded"

FIXES
- [Accounts] Register device token to database for future notification support
- [Accounts] Improve validation for email and passwords
- [Radarr] Fix some durations being displayed twice
- [UI/TextField] Show errors as snackbar instead of in-line error
This commit is contained in:
Jagandeep Brar
2021-01-02 13:07:10 -06:00
committed by GitHub
parent 319b35d226
commit b76de299e2
24 changed files with 195 additions and 229 deletions

View File

@@ -1,5 +1,16 @@
# LunaSea Changelog
## [Beta] v4.2.0 (40200006)
#### TWEAKS
- `[Sonarr/Upcoming]` If an episode has not aired, show it as "Upcoming" instead of "Not Downloaded"
#### FIXES
- `[Accounts]` Register device token to database for future notification support
- `[Accounts]` Improve validation for email and passwords
- `[Radarr]` Fix some durations being displayed twice
- `[UI/TextField]` Show errors as snackbar instead of in-line error
## [Beta] v4.2.0 (40200005)
#### NEW

View File

@@ -1,116 +1,36 @@
{
"motd": "This update introduces some exciting new features, including a brand new cloud account system! You can register for a free LunaSea account to backup an unlimited amount of configurations and easily restore configurations from previous devices.\n\nBackups also now support backing up the entire database, including customization details for each module.",
"motd": "This update includes some minor tweaks and fixes for the last build.",
"version": "4.2.0",
"build": "40200005",
"new": [
{
"module": "Accounts",
"changes": [
"Added LunaSea accounts",
"Ability to backup, restore, and delete cloud configurations"
]
},
{
"module": "Backups",
"changes": [
"All backups now contain all customization and configuration options in LunaSea"
]
},
{
"module": "Changelog",
"changes": [
"Show changelog on launch if a new version is installed",
"Use a new bottom sheet UI for the changelog"
]
},
{
"module": "Settings/Modules",
"changes": [
"Add an information/help button with module descriptions and links"
]
}
],
"build": "40200006",
"new": [],
"tweaks": [
{
"module": "Radarr",
"module": "Sonarr/Upcoming",
"changes": [
"Always show the amount of days content will be available instead of limiting it to only content in the next 30 days"
]
},
{
"module": "Settings",
"changes": [
"Moved \"Backup & Restore\" and \"Logs\" to \"System\" section",
"Merged \"Customization\" and \"Modules\" sections into \"Configuration\""
]
},
{
"module": "Settings/Sonarr",
"changes": [
"Removed the need to enable Sonarr to test the connection"
]
},
{
"module": "Settings/Tautulli",
"changes": [
"Removed the need to enable Tautulli to test the connection"
"If an episode has not aired, show it as \"Upcoming\" instead of \"Not Downloaded\""
]
}
],
"fixes": [
{
"module": "In App Purchases",
"module": "Accounts",
"changes": [
"Ensure all in app purchases are marked as \"consumed\""
"Register device token to database for future notification support",
"Improve validation for email and passwords"
]
},
{
"module": "Flutter",
"module": "Radarr",
"changes": [
"Updated packages"
"Fix some durations being displayed twice"
]
},
{
"module": "Logging",
"module": "UI/TextField",
"changes": [
"Updated Sentry to v4 framework to improve capturing fatal/crashing bugs"
]
},
{
"module": "Settings/Logs",
"changes": [
"Hide exception and stack trace buttons when an error is not available"
]
},
{
"module": "Settings/Resources",
"changes": [
"Updated URL endpoints"
]
},
{
"module": "Sonarr/History",
"changes": [
"Fixed history fetching the oldest entries, not the newest"
]
},
{
"module": "State",
"changes": [
"Correctly clear state when clearing LunaSea's configuration"
]
},
{
"module": "Tautulli/Activity",
"changes": [
"Fixed consistency of hardware transcoding indicator compared to the web UI"
]
},
{
"module": "UI/Divider",
"changes": [
"Fix consistency of divider width across regular and AMOLED dark theme"
"Show errors as snackbar instead of in-line error"
]
}
]
}

View File

@@ -37,6 +37,7 @@ class LunaFirebaseAuth {
try {
assert(email != null && password != null);
UserCredential _user = await instance.createUserWithEmailAndPassword(email: email, password: password);
LunaFirebaseFirestore().addDeviceToken();
return LunaFirebaseAuthResponse(state: true, user: _user.user, error: null);
} on FirebaseAuthException catch (error) {
return LunaFirebaseAuthResponse(state: false, user: null, error: error);
@@ -53,6 +54,7 @@ class LunaFirebaseAuth {
try {
assert(email != null && password != null);
UserCredential _user = await instance.signInWithEmailAndPassword(email: email, password: password);
LunaFirebaseFirestore().addDeviceToken();
return LunaFirebaseAuthResponse(state: true, user: _user.user, error: null);
} on FirebaseAuthException catch (error) {
return LunaFirebaseAuthResponse(state: false, user: null, error: error);

View File

@@ -11,7 +11,7 @@ class LunaFirebaseFirestore {
///
/// If the user is not signed in, returns false.
Future<bool> addBackupEntry(String id, int timestamp, { String title = '', String description = '' }) async {
if(LunaFirebaseAuth().user == null) return false;
if(!LunaFirebaseAuth().isSignedIn) return false;
try {
LunaFirebaseBackupDocument entry = LunaFirebaseBackupDocument(id: id, title: title, description: description, timestamp: timestamp);
instance.doc('users/${LunaFirebaseAuth().uid}/backups/$id').set(entry.toJSON());
@@ -26,7 +26,7 @@ class LunaFirebaseFirestore {
///
/// If the user is not signed in, returns false.
Future<bool> deleteBackupEntry(String id) async {
if(LunaFirebaseAuth().user == null) return false;
if(!LunaFirebaseAuth().isSignedIn) return false;
try {
await instance.doc('users/${LunaFirebaseAuth().uid}/backups/$id').delete();
return true;
@@ -49,4 +49,19 @@ class LunaFirebaseFirestore {
return [];
}
}
/// Add the current device token to Firestore. Returns true if successful, and false on any error.
Future<bool> addDeviceToken() async {
if(!LunaFirebaseAuth().isSignedIn) return false;
try {
String token = await LunaFirebaseMessaging.instance.getToken();
instance.doc('users/${LunaFirebaseAuth().uid}').set({
'devices': FieldValue.arrayUnion([token]),
}, SetOptions(merge: true));
return true;
} catch (error, stack) {
LunaLogger().error('Failed to add device token', error, stack);
return false;
}
}
}

View File

@@ -1,5 +1,6 @@
export 'luna_ui/appbar.dart';
export 'luna_ui/decoration.dart';
export 'luna_ui/sliver_sticky_header.dart';
export 'luna_ui/snackbar.dart';
export 'luna_ui/text.dart';

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
class LunaSliverStickyHeader extends SliverStickyHeader {
LunaSliverStickyHeader({
@required Widget header,
@required List<Widget> children,
}) : super(
header: header,
sliver: SliverPadding(
sliver: SliverList(
delegate: SliverChildListDelegate(children),
),
padding: EdgeInsets.symmetric(vertical: 8.0),
),
);
}

View File

@@ -19,6 +19,5 @@ export 'ui/network_image.dart';
export 'ui/refresh_indicator.dart';
export 'ui/shape.dart';
export 'ui/snackbar.dart';
export 'ui/sticky_header.dart';
export 'ui/table.dart';
export 'ui/text.dart';

View File

@@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
class LSStickyHeader extends StatelessWidget {
final List<Widget> children;
final Widget header;
LSStickyHeader({
@required this.header,
@required this.children,
});
@override
Widget build(BuildContext context) => SliverStickyHeader(
header: header,
sliver: SliverPadding(
sliver: SliverList(
delegate: (SliverChildListDelegate(children)),
),
padding: EdgeInsets.symmetric(vertical: 8.0),
),
);
}

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
@@ -72,8 +71,9 @@ class _State extends State<LunaBIOS> {
/// Runs the first-step boot sequence that is required for widgets
Future<void> _boot() async {
LunaFirebaseMessaging().requestNotificationPermissions();
if(kDebugMode) print(await LunaFirebaseMessaging.instance.getToken());
// Request notifications and register the device token.
await LunaFirebaseMessaging().requestNotificationPermissions();
LunaFirebaseFirestore().addDeviceToken();
}
@override

View File

@@ -79,7 +79,7 @@ class _State extends State<LidarrAddSearch> {
Widget _list(List<Widget> data) => LSListViewStickyHeader(
controller: _scrollController,
slivers: <Widget>[
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchBar,
children: data,
)

View File

@@ -115,7 +115,7 @@ class _State extends State<LidarrCatalogue> with AutomaticKeepAliveClientMixin {
return LSListViewStickyHeader(
controller: _scrollController,
slivers: <Widget>[
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchSortBar,
children: _children,
),

View File

@@ -118,7 +118,7 @@ class _State extends State<LidarrSearchResults> {
return LSListViewStickyHeader(
controller: _scrollController,
slivers: [
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchSortBar,
children: _children,
)

View File

@@ -106,7 +106,7 @@ class _State extends State<NZBGetHistory> with AutomaticKeepAliveClientMixin {
return LSListViewStickyHeader(
controller: _scrollController,
slivers: <Widget>[
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchBar,
children: _children,
),

View File

@@ -49,7 +49,7 @@ class RadarrMissingData {
return '\t\t$profile';
}
String get runtimeString => '\t${Constants.TEXT_BULLET}\t${runtime.lunaRuntime()}${runtime.lunaRuntime()}';
String get runtimeString => runtime.lunaRuntime().isEmpty ? '' : '\t${Constants.TEXT_BULLET}\t${runtime.lunaRuntime()}';
List<TextSpan> get subtitle {
DateTime now = DateTime.now();

View File

@@ -79,7 +79,7 @@ class _State extends State<RadarrAddSearch> {
Widget _list(List<Widget> data) => LSListViewStickyHeader(
controller: _scrollController,
slivers: <Widget>[
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchBar,
children: data,
),

View File

@@ -114,7 +114,7 @@ class _State extends State<RadarrCatalogue> with AutomaticKeepAliveClientMixin {
return LSListViewStickyHeader(
controller: _scrollController,
slivers: <Widget>[
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchSortBar,
children: _children,
),

View File

@@ -118,7 +118,7 @@ class _State extends State<RadarrSearchResults> {
return LSListViewStickyHeader(
controller: _scrollController,
slivers: [
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchSortBar,
children: _children,
)

View File

@@ -106,7 +106,7 @@ class _State extends State<SABnzbdHistory> with AutomaticKeepAliveClientMixin {
return LSListViewStickyHeader(
controller: _scrollController,
slivers: <Widget>[
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchBar,
children: _children,
),

View File

@@ -95,7 +95,7 @@ class _State extends State<SearchResults> {
return LSListViewStickyHeader(
controller: _scrollController,
slivers: [
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchSortBar,
children: _children,
)

View File

@@ -73,7 +73,7 @@ class _State extends State<SearchSearch> {
Widget _list(List<Widget> data) => LSListViewStickyHeader(
controller: _scrollController,
slivers: <Widget>[
LSStickyHeader(
LunaSliverStickyHeader(
header: _searchBar,
children: data,
),

View File

@@ -7,116 +7,135 @@ class SettingsAccountSignedOutBody extends StatefulWidget {
}
class _State extends State<SettingsAccountSignedOutBody> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
LunaLoadingState _state = LunaLoadingState.INACTIVE;
@override
Widget build(BuildContext context) => Form(
key: _formKey,
child: LSListView(
children: [
Padding(
child: Center(
child: Image.asset(
'assets/branding/splash.png',
width: 200.0,
),
),
padding: EdgeInsets.symmetric(vertical: 16.0),
),
AutofillGroup(
child: LSCard(
child: Column(
children: [
LSTextInputBar(
controller: _emailController,
isFormField: true,
margin: EdgeInsets.only(top: 12.0, bottom: 6.0, left: 12.0, right: 12.0),
labelIcon: Icons.person,
labelText: 'Email...',
action: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
autofillHints: [AutofillHints.username, AutofillHints.email],
onChanged: (value, updateController) => setState(() {
if(updateController) _emailController.text = value;
}),
validator: (value) {
if(value.isEmpty) return 'Email Required';
return null;
},
),
LSTextInputBar(
controller: _passwordController,
isFormField: true,
margin: EdgeInsets.only(top: 6.0, bottom: 12.0, left: 12.0, right: 12.0),
labelIcon: Icons.vpn_key,
labelText: 'Password...',
obscureText: true,
keyboardType: TextInputType.text,
autofillHints: [AutofillHints.password, AutofillHints.newPassword],
action: TextInputAction.done,
onChanged: (value, updateController) => setState(() {
if(updateController) _passwordController.text = value;
}),
validator: (value) {
if(value.isEmpty) return 'Password Required';
return null;
},
),
],
),
Widget build(BuildContext context) => LSListView(
children: [
Padding(
child: Center(
child: Image.asset(
'assets/branding/splash.png',
width: 200.0,
),
),
LSContainerRow(
children: [
Expanded(
child: LSButton(
text: 'Register',
backgroundColor: LunaColours.blueGrey,
onTap: _register,
reducedMargin: true,
isLoading: _state == LunaLoadingState.ACTIVE,
padding: EdgeInsets.symmetric(vertical: 16.0),
),
AutofillGroup(
child: LSCard(
child: Column(
children: [
LSTextInputBar(
controller: _emailController,
isFormField: true,
margin: EdgeInsets.all(12.0),
labelIcon: Icons.person,
labelText: 'Email',
action: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
autofillHints: [AutofillHints.username, AutofillHints.email],
onChanged: (value, updateController) => setState(() {
if(updateController) _emailController.text = value;
}),
),
),
Expanded(
child: LSButton(
text: 'Sign In',
onTap: _signIn,
reducedMargin: true,
isLoading: _state == LunaLoadingState.ACTIVE,
LSTextInputBar(
controller: _passwordController,
isFormField: true,
margin: EdgeInsets.only(bottom: 12.0, left: 12.0, right: 12.0),
labelIcon: Icons.vpn_key,
labelText: 'Password',
obscureText: true,
keyboardType: TextInputType.text,
autofillHints: [AutofillHints.password, AutofillHints.newPassword],
action: TextInputAction.done,
onChanged: (value, updateController) => setState(() {
if(updateController) _passwordController.text = value;
}),
),
),
],
],
),
),
],
),
),
LSContainerRow(
children: [
Expanded(
child: LSButton(
text: 'Register',
backgroundColor: LunaColours.blueGrey,
onTap: _register,
reducedMargin: true,
isLoading: _state == LunaLoadingState.ACTIVE,
),
),
Expanded(
child: LSButton(
text: 'Sign In',
onTap: _signIn,
reducedMargin: true,
isLoading: _state == LunaLoadingState.ACTIVE,
),
),
],
),
],
);
bool _validateEmailAddress({ bool showSnackBarOnFailure = true }) {
const _regex = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)";
if(!RegExp(_regex).hasMatch(_emailController.text)) {
if(showSnackBarOnFailure) showLunaErrorSnackBar(
context: context,
title: 'Invalid Email',
message: 'The email address is invalid',
);
return false;
}
return true;
}
bool _validatePassword({ bool showSnackBarOnFailure = true }) {
if(_passwordController.text.isEmpty) {
if(showSnackBarOnFailure) showLunaErrorSnackBar(
context: context,
title: 'Invalid Password',
message: 'The password is invalid',
);
return false;
}
return true;
}
Future<void> _register() async {
// Set button state
if(!_validateEmailAddress() || !_validatePassword()) return;
if(mounted) setState(() => _state = LunaLoadingState.ACTIVE);
// Check form, then register user
if(_formKey.currentState.validate()) await LunaFirebaseAuth().registerUser(_emailController.text, _passwordController.text)
.then((response) => response.state
await LunaFirebaseAuth().registerUser(_emailController.text, _passwordController.text)
.then((response) {
if(mounted) setState(() => _state = LunaLoadingState.INACTIVE);
response.state
? showLunaSuccessSnackBar(context: context, title: 'Successfully Registered', message: response.user.email)
: showLunaErrorSnackBar(context: context, title: 'Failed to Register', message: response.error?.message ?? 'Unknown Error'))
.catchError((error, stack) => showLunaErrorSnackBar(context: context, title: 'Failed to Register', error: error));
// Set button state
if(mounted) setState(() => _state = LunaLoadingState.INACTIVE);
: showLunaErrorSnackBar(context: context, title: 'Failed to Register', message: response.error?.message ?? 'Unknown Error');
})
.catchError((error, stack) {
if(mounted) setState(() => _state = LunaLoadingState.INACTIVE);
showLunaErrorSnackBar(context: context, title: 'Failed to Register', error: error);
});
}
Future<void> _signIn() async {
// Set button state
if(!_validateEmailAddress() || !_validatePassword()) return;
if(mounted) setState(() => _state = LunaLoadingState.ACTIVE);
// Check form, then login user
if(_formKey.currentState.validate()) await LunaFirebaseAuth().signInUser(_emailController.text, _passwordController.text)
.then((response) => response.state
await LunaFirebaseAuth().signInUser(_emailController.text, _passwordController.text)
.then((response) {
if(mounted) setState(() => _state = LunaLoadingState.INACTIVE);
response.state
? showLunaSuccessSnackBar(context: context, title: 'Successfully Signed In', message: response.user.email)
: showLunaErrorSnackBar(context: context, title: 'Failed to Sign In', message: response.error?.message ?? 'Unknown Error'))
.catchError((error, stack) => showLunaErrorSnackBar(context: context, title: 'Failed to Sign In', error: error));
// Set button state
if(mounted) setState(() => _state = LunaLoadingState.INACTIVE);
: showLunaErrorSnackBar(context: context, title: 'Failed to Sign In', message: response.error?.message ?? 'Unknown Error');
})
.catchError((error, stack) {
if(mounted) setState(() => _state = LunaLoadingState.INACTIVE);
showLunaErrorSnackBar(context: context, title: 'Failed to Sign In', error: error);
});
}
}
}

View File

@@ -9,4 +9,9 @@ extension SonarrCalendarExtension on SonarrCalendar {
: DateFormat('hh:mm a').format(this.airDateUtc.toLocal());
return Constants.TEXT_EMDASH;
}
}
bool get lunaHasAired {
if(this.airDateUtc != null) return DateTime.now().isAfter(this.airDateUtc.toLocal());
return false;
}
}

View File

@@ -138,9 +138,9 @@ class _State extends State<SonarrUpcomingTile> {
children: [
if(!widget.record.hasFile) TextSpan(
style: TextStyle(
color: LunaColours.red,
color: widget.record.lunaHasAired ? LunaColours.red : LunaColours.blue,
),
text: 'Not Downloaded'
text: widget.record.lunaHasAired ? 'Not Downloaded' : 'Upcoming',
),
if(widget.record.hasFile) TextSpan(
style: TextStyle(

View File

@@ -1,6 +1,6 @@
name: lunasea
description: Self-Hosted Controller
version: 4.2.0+40200005
version: 4.2.0+40200006
publish_to: 'none'
environment:
sdk: ">=2.7.0 <3.0.0"