From 22deb64bcef3e13dc8e663649f5d7df4f7bf4e4b Mon Sep 17 00:00:00 2001 From: Nutcake Date: Wed, 3 May 2023 23:24:27 +0200 Subject: [PATCH] Add notification requester and settings entries --- lib/clients/settings_client.dart | 2 +- lib/main.dart | 36 ----------- lib/models/settings.dart | 62 +++++++++++++----- lib/widgets/friends_list.dart | 7 +- lib/widgets/login_screen.dart | 45 ++++++++++++- lib/widgets/settings_page.dart | 107 ++++++++++++++++++++++++++++--- 6 files changed, 192 insertions(+), 67 deletions(-) diff --git a/lib/clients/settings_client.dart b/lib/clients/settings_client.dart index 3585cf5..fe8892f 100644 --- a/lib/clients/settings_client.dart +++ b/lib/clients/settings_client.dart @@ -7,7 +7,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class SettingsClient { static const String _settingsKey = "settings"; static const _storage = FlutterSecureStorage(); - Settings _currentSettings = Settings.def(); + Settings _currentSettings = const Settings(); Settings get currentSettings => _currentSettings; diff --git a/lib/main.dart b/lib/main.dart index b387409..fa96542 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,6 @@ import 'package:contacts_plus_plus/widgets/login_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logging/logging.dart'; import 'clients/api_client.dart'; import 'models/authentication_data.dart'; @@ -49,41 +48,6 @@ class _ContactsPlusPlusState extends State { const FriendsList() : LoginScreen( onLoginSuccessful: (AuthenticationData authData) async { - final notificationManager = FlutterLocalNotificationsPlugin(); - final settings = widget.settingsClient.currentSettings; - final platformNotifications = notificationManager.resolvePlatformSpecificImplementation(); - if (!settings.notificationsDenied && !(await platformNotifications?.areNotificationsEnabled() ?? true)) { - if (context.mounted) { - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text("This app needs to ask your permission to send background notifications."), - content: Text("Are you okay with that?"), - actions: [ - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await widget.settingsClient.changeSettings(settings.copyWith(notificationsDenied: true)); - }, - child: const Text("No"), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await widget.settingsClient.changeSettings(settings.copyWith(notificationsDenied: false)); - await notificationManager.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() - ?.requestPermission(); - }, - child: const Text("Yes"), - ) - ], - ); - }, - ); - } - } if (authData.isAuthenticated) { setState(() { _authData = authData; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 6b92e80..7e35257 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,24 +1,52 @@ +class SettingsEntry { + final T? value; + final T deflt; -class Settings { - static const Settings _defaultSettings = Settings(notificationsDenied: true, unreadCheckIntervalMinutes: 0); - final bool notificationsDenied; - final int unreadCheckIntervalMinutes; + const SettingsEntry({this.value, required this.deflt}); - const Settings({required this.notificationsDenied, required this.unreadCheckIntervalMinutes}); - - factory Settings.def() => _defaultSettings; - - factory Settings.fromMap(Map map) { - return Settings( - notificationsDenied: map["notificationsDenied"] ?? _defaultSettings.notificationsDenied, - unreadCheckIntervalMinutes: map["unreadCheckIntervalMinutes"] ?? _defaultSettings.unreadCheckIntervalMinutes, + factory SettingsEntry.fromMap(Map map) { + return SettingsEntry( + value: map["value"] as T, + deflt: map["default"], ); } Map toMap() { return { - "notificationsDenied": notificationsDenied, - "unreadCheckIntervalMinutes": unreadCheckIntervalMinutes, + "value": value.toString(), + "default": deflt, + }; + } + + T get valueOrDefault => value ?? deflt; + + SettingsEntry withValue({required T newValue}) => SettingsEntry(value: newValue, deflt: deflt); + + SettingsEntry passThrough(T? newValue) { + return newValue == null ? this : this.withValue(newValue: newValue); + } +} + +class Settings { + final SettingsEntry notificationsDenied; + final SettingsEntry unreadCheckIntervalMinutes; + + const Settings({ + this.notificationsDenied = const SettingsEntry(deflt: false), + this.unreadCheckIntervalMinutes = const SettingsEntry(deflt: 60), + }); + + factory Settings.fromMap(Map map) { + return Settings( + notificationsDenied: SettingsEntry.fromMap(map["notificationsDenied"]), + unreadCheckIntervalMinutes: SettingsEntry.fromMap(map["unreadCheckIntervalMinutes"]), + ); + } + + Map toMap() { + return { + "notificationsDenied": notificationsDenied.toMap(), + "unreadCheckIntervalMinutes": unreadCheckIntervalMinutes.toMap(), }; } @@ -26,8 +54,10 @@ class Settings { Settings copyWith({bool? notificationsDenied, int? unreadCheckIntervalMinutes}) { return Settings( - notificationsDenied: notificationsDenied ?? this.notificationsDenied, - unreadCheckIntervalMinutes: unreadCheckIntervalMinutes ?? this.unreadCheckIntervalMinutes, + notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), + unreadCheckIntervalMinutes: this.unreadCheckIntervalMinutes.passThrough(unreadCheckIntervalMinutes), ); } + + } \ No newline at end of file diff --git a/lib/widgets/friends_list.dart b/lib/widgets/friends_list.dart index d28af06..331a3e1 100644 --- a/lib/widgets/friends_list.dart +++ b/lib/widgets/friends_list.dart @@ -10,6 +10,7 @@ import 'package:contacts_plus_plus/widgets/expanding_input_fab.dart'; import 'package:contacts_plus_plus/widgets/friend_list_tile.dart'; import 'package:contacts_plus_plus/widgets/settings_page.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' as fln; class FriendsList extends StatefulWidget { const FriendsList({super.key}); @@ -32,7 +33,7 @@ class _FriendsListState extends State { } @override - void didChangeDependencies() { + void didChangeDependencies() async { super.didChangeDependencies(); final clientHolder = ClientHolder.of(context); if (_clientHolder != clientHolder) { @@ -89,7 +90,7 @@ class _FriendsListState extends State { RefreshIndicator( onRefresh: () async { _refreshFriendsList(); - await _friendsFuture; + await _friendsFuture; // Keep the indicator running until everything's loaded }, child: FutureBuilder( future: _friendsFuture, @@ -133,7 +134,7 @@ class _FriendsListState extends State { }, ); } else { - return const SizedBox.shrink(); + return const LinearProgressIndicator(); } } ), diff --git a/lib/widgets/login_screen.dart b/lib/widgets/login_screen.dart index 3e74ba4..d32e7c8 100644 --- a/lib/widgets/login_screen.dart +++ b/lib/widgets/login_screen.dart @@ -1,6 +1,7 @@ import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/models/authentication_data.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({this.onLoginSuccessful, this.cachedUsername, super.key}); @@ -17,7 +18,7 @@ class _LoginScreenState extends State { final TextEditingController _passwordController = TextEditingController(); late final Future _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async { if (value.isAuthenticated) { - await widget.onLoginSuccessful?.call(value); + await loginSuccessful(value); } return value; }); @@ -68,7 +69,7 @@ class _LoginScreenState extends State { _error = ""; _isLoading = false; }); - await widget.onLoginSuccessful?.call(authData); + await loginSuccessful(authData); } catch (e, s) { FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); setState(() { @@ -78,6 +79,44 @@ class _LoginScreenState extends State { } } + Future loginSuccessful(AuthenticationData authData) async { + final settingsClient = ClientHolder.of(context).settingsClient; + final notificationManager = FlutterLocalNotificationsPlugin(); + if (settingsClient.currentSettings.notificationsDenied.value == null) { + if (context.mounted) { + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("This app needs to ask your permission to send background notifications."), + content: const Text("Are you okay with that?"), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await settingsClient.changeSettings(settingsClient.currentSettings.copyWith(notificationsDenied: true)); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final requestResult = await notificationManager.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestPermission(); + await settingsClient.changeSettings(settingsClient.currentSettings.copyWith(notificationsDenied: requestResult)); + }, + child: const Text("Yes"), + ) + ], + ); + }, + ); + } + } + await widget.onLoginSuccessful?.call(authData); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -129,7 +168,7 @@ class _LoginScreenState extends State { ), ), if (_isLoading) - const LinearProgressIndicator() + const CircularProgressIndicator() else TextButton.icon( onPressed: submit, diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index b8c282c..7bfdb52 100644 --- a/lib/widgets/settings_page.dart +++ b/lib/widgets/settings_page.dart @@ -5,8 +5,10 @@ import 'package:url_launcher/url_launcher.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); + @override Widget build(BuildContext context) { + final sClient = ClientHolder.of(context).settingsClient; return Scaffold( appBar: AppBar( leading: IconButton( @@ -19,11 +21,39 @@ class SettingsPage extends StatelessWidget { ), body: ListView( children: [ + const ListSectionHeader(name: "Notifications"), + BooleanSettingsTile( + title: "Send Notifications", + initialState: sClient.currentSettings.notificationsDenied.valueOrDefault, + onChanged: (value) async => await sClient.changeSettings(sClient.currentSettings.copyWith(notificationsDenied: value)), + ), + ListTile( + trailing: const Icon(Icons.logout), + title: const Text("Sign out"), + onTap: () { + showDialog( + context: context, + builder: (context) => + AlertDialog( + title: Text("Are you sure you want to sign out?", style: Theme + .of(context) + .textTheme + .titleLarge,), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")), + TextButton( + onPressed: () async { + await ClientHolder.of(context).apiClient.logout(context); + }, + child: const Text("Yes"), + ), + ], + ), + ); + }, + ), + const ListSectionHeader(name: "Other"), ListTile( - shape: Border( - bottom: BorderSide(color: Theme.of(context).colorScheme.secondaryContainer, width: 0.5), - top: BorderSide(color: Theme.of(context).colorScheme.secondaryContainer, width: 0.5) - ), trailing: const Icon(Icons.logout), title: const Text("Sign out"), onTap: () { @@ -49,10 +79,6 @@ class SettingsPage extends StatelessWidget { }, ), ListTile( - shape: Border( - bottom: BorderSide(color: Theme.of(context).colorScheme.secondaryContainer, width: 0.5), - top: BorderSide(color: Theme.of(context).colorScheme.secondaryContainer, width: 0.5) - ), trailing: const Icon(Icons.info_outline), title: const Text("About Contacts++"), onTap: () { @@ -81,4 +107,69 @@ class SettingsPage extends StatelessWidget { ), ); } +} + +class ListSectionHeader extends StatelessWidget { + const ListSectionHeader({required this.name, super.key}); + + final String name; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(name, style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + color: Colors.white12, + height: 1, + ), + ) + ], + ), + ); + } +} + +class BooleanSettingsTile extends StatefulWidget { + const BooleanSettingsTile({required this.title, required this.initialState, required this.onChanged, super.key}); + + final String title; + final bool initialState; + final Function(bool) onChanged; + + @override + State createState() => _BooleanSettingsTileState(); +} + +class _BooleanSettingsTileState extends State { + late bool state = widget.initialState; + + @override + Widget build(BuildContext context) { + return ListTile( + trailing: Switch( + onChanged: (value) async { + await widget.onChanged(value); + setState(() { + state = value; + }); + }, + value: state, + ), + title: Text(widget.title), + onTap: () async { + await widget.onChanged(!state); + setState(() { + state = !state; + }); + }, + ); + } + } \ No newline at end of file