parent
06824784cb
commit
0ef9b25320
20 changed files with 970 additions and 28 deletions
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Roboto.ttf
Normal file
BIN
assets/fonts/Roboto.ttf
Normal file
Binary file not shown.
27
lib/controllers/settings_controller.dart
Normal file
27
lib/controllers/settings_controller.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import "dart:convert";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/settings_file_controller.dart";
|
||||
import "package:nexus/models/settings.dart";
|
||||
|
||||
class SettingsController extends AsyncNotifier<Settings> {
|
||||
@override
|
||||
Future<Settings> build() async {
|
||||
final file = await ref.watch(SettingsFileController.provider.future);
|
||||
|
||||
try {
|
||||
return Settings.fromJson(json.decode(await file.readAsString()));
|
||||
} catch (_) {
|
||||
return Settings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set(Settings settings) async {
|
||||
state = AsyncData(settings);
|
||||
final file = await ref.watch(SettingsFileController.provider.future);
|
||||
await file.writeAsString(json.encode(settings.toJson()));
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider<SettingsController, Settings>(
|
||||
SettingsController.new,
|
||||
);
|
||||
}
|
||||
23
lib/controllers/settings_file_controller.dart
Normal file
23
lib/controllers/settings_file_controller.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import "dart:io";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:xdg_directories/xdg_directories.dart";
|
||||
|
||||
class SettingsFileController extends AsyncNotifier<File> {
|
||||
@override
|
||||
Future<File> build() async {
|
||||
final directory = await switch (Platform.isLinux) {
|
||||
true => Directory(
|
||||
join(configHome.absolute.path, "nexus"),
|
||||
).create(recursive: true),
|
||||
false => getApplicationSupportDirectory(),
|
||||
};
|
||||
|
||||
return File(join(directory.absolute.path, "config.json"));
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider<SettingsFileController, File>(
|
||||
SettingsFileController.new,
|
||||
);
|
||||
}
|
||||
98
lib/controllers/settings_sections_controller.dart
Normal file
98
lib/controllers/settings_sections_controller.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import "dart:io";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:nexus/controllers/settings_controller.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||
|
||||
class SettingsSectionsController
|
||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||
@override
|
||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||
final settings = await ref.watch(SettingsController.provider.future);
|
||||
|
||||
return .new({
|
||||
"General": .new([
|
||||
.new(
|
||||
title: "Appearance",
|
||||
icon: Icons.brush,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Theme",
|
||||
description:
|
||||
"Toggle between Light Mode, Dark Mode, and System themes.",
|
||||
icon: Icons.contrast,
|
||||
builder: (title, description, icon) => DialogListTile<ThemeMode>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
initialValue: settings.theme,
|
||||
options: ThemeMode.values,
|
||||
getName: (option) => toBeginningOfSentenceCase(option.name),
|
||||
onChanged: (value) => ref
|
||||
.watch(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(theme: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Use Dynamic Theme",
|
||||
icon: Icons.palette,
|
||||
description:
|
||||
"Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.",
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.useDynamicTheming,
|
||||
onChanged:
|
||||
(Platform.isAndroid ||
|
||||
Platform.isLinux ||
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows)
|
||||
? (value) => ref
|
||||
.watch(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(useDynamicTheming: value))
|
||||
.onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Behavior",
|
||||
icon: Icons.psychology,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Linux Mobile Mode",
|
||||
description:
|
||||
"Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
|
||||
icon: Icons.construction,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.linuxMobileMode,
|
||||
onChanged: Platform.isLinux
|
||||
? (value) => ref
|
||||
.watch(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(linuxMobileMode: value))
|
||||
.onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider<
|
||||
SettingsSectionsController,
|
||||
IMap<String, IList<SettingsCategory>>
|
||||
>(SettingsSectionsController.new);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ extension SchemeToTheme on ColorScheme {
|
|||
backgroundColor: surfaceContainerLow,
|
||||
),
|
||||
textTheme: ThemeData(
|
||||
fontFamilyFallback: ["sans", "emoji"],
|
||||
fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"],
|
||||
brightness: brightness,
|
||||
).textTheme,
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
|
|
|
|||
83
lib/helpers/extensions/show_about_dialog.dart
Normal file
83
lib/helpers/extensions/show_about_dialog.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_svg/flutter_svg.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:m3e_card_list/m3e_card_list.dart";
|
||||
import "package:nexus/helpers/launch_helper.dart";
|
||||
import "package:package_info_plus/package_info_plus.dart";
|
||||
|
||||
extension ShowContextMenu on BuildContext {
|
||||
Future<void> showAboutDialog(WidgetRef ref) async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: this,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
spacing: 16,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
SvgPicture.asset("assets/icon.svg", width: 64),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: .center,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
"Nexus",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text("(${packageInfo.version})"),
|
||||
],
|
||||
),
|
||||
|
||||
Text(
|
||||
"A simple and user-friendly Matrix client",
|
||||
overflow: .ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
M3ECardColumn(
|
||||
onTap: (index) =>
|
||||
ref.watch(LaunchHelper.provider).launchUrl(switch (index) {
|
||||
0 => Uri.https("git.federated.nexus", "nexus/nexus"),
|
||||
_ => Uri.https("liberapay.com", "QuadRadical"),
|
||||
}),
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.commit),
|
||||
title: Text("Source Code"),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.favorite, color: Colors.pinkAccent),
|
||||
title: Text("Donate"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => showLicensePage(context: context),
|
||||
child: Text("View licenses"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text("Close"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
196
lib/helpers/font_licenses.dart
Normal file
196
lib/helpers/font_licenses.dart
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import "package:flutter/foundation.dart";
|
||||
|
||||
const fontLicenses = [
|
||||
LicenseEntryWithLineBreaks(
|
||||
["Noto Color Emoji"],
|
||||
"""Copyright 2021 Google Inc. All Rights Reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.""",
|
||||
),
|
||||
LicenseEntryWithLineBreaks(
|
||||
["Roboto"],
|
||||
"""Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.""",
|
||||
),
|
||||
];
|
||||
|
|
@ -8,9 +8,11 @@ import "package:nexus/controllers/client_controller.dart";
|
|||
import "package:nexus/controllers/client_state_controller.dart";
|
||||
import "package:nexus/controllers/header_controller.dart";
|
||||
import "package:nexus/controllers/multi_provider_controller.dart";
|
||||
import "package:nexus/controllers/settings_controller.dart";
|
||||
import "package:nexus/controllers/shared_prefs_controller.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/scheme_to_theme.dart";
|
||||
import "package:nexus/helpers/font_licenses.dart";
|
||||
import "package:nexus/pages/chat_page.dart";
|
||||
import "package:nexus/pages/select_server_page.dart";
|
||||
import "package:nexus/pages/verify_page.dart";
|
||||
|
|
@ -70,6 +72,8 @@ void main() async {
|
|||
await windowManager.setMinimumSize(Size.square(500));
|
||||
}
|
||||
|
||||
LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses));
|
||||
|
||||
FlutterError.onError = (FlutterErrorDetails details) =>
|
||||
showError(details.exception.toString(), details.stack);
|
||||
|
||||
|
|
@ -90,20 +94,43 @@ class App extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) => DynamicColorBuilder(
|
||||
builder: (lightDynamic, darkDynamic) => MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
// Use indigo to work around bugs in theme generation
|
||||
theme: (lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo))
|
||||
.theme,
|
||||
darkTheme:
|
||||
(darkDynamic ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
))
|
||||
.theme,
|
||||
home: Scaffold(
|
||||
builder: (lightDynamic, darkDynamic) => Consumer(
|
||||
builder: (context, ref, child) => MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
// Use indigo to work around bugs in theme generation
|
||||
theme:
|
||||
(ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
orElse: () => lightDynamic,
|
||||
data: (settings) =>
|
||||
settings.useDynamicTheming ? lightDynamic : null,
|
||||
) ??
|
||||
ColorScheme.fromSeed(seedColor: Colors.indigo))
|
||||
.theme,
|
||||
darkTheme:
|
||||
(ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
orElse: () => darkDynamic,
|
||||
data: (settings) =>
|
||||
settings.useDynamicTheming ? darkDynamic : null,
|
||||
) ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
))
|
||||
.theme,
|
||||
themeMode: ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
data: (settings) => settings.theme,
|
||||
orElse: () => ThemeMode.system,
|
||||
),
|
||||
home: child,
|
||||
),
|
||||
child: Scaffold(
|
||||
body: Consumer(
|
||||
builder: (_, ref, _) => ref
|
||||
.watch(
|
||||
|
|
|
|||
16
lib/models/setting.dart
Normal file
16
lib/models/setting.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
class Setting {
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final Widget Function(String title, String description, IconData icon)
|
||||
builder;
|
||||
|
||||
Setting({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.builder,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
16
lib/models/settings.dart
Normal file
16
lib/models/settings.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
part "settings.freezed.dart";
|
||||
part "settings.g.dart";
|
||||
|
||||
@freezed
|
||||
abstract class Settings with _$Settings {
|
||||
const factory Settings({
|
||||
@Default(ThemeMode.system) ThemeMode theme,
|
||||
@Default(true) bool useDynamicTheming,
|
||||
@Default(false) bool linuxMobileMode,
|
||||
}) = _Settings;
|
||||
|
||||
factory Settings.fromJson(Map<String, Object?> json) =>
|
||||
_$SettingsFromJson(json);
|
||||
}
|
||||
14
lib/models/settings_category.dart
Normal file
14
lib/models/settings_category.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/setting.dart";
|
||||
part "settings_category.freezed.dart";
|
||||
|
||||
@freezed
|
||||
abstract class SettingsCategory with _$SettingsCategory {
|
||||
const factory SettingsCategory({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required IList<Setting> settings,
|
||||
}) = _SettingsCategory;
|
||||
}
|
||||
77
lib/pages/settings_category_page.dart
Normal file
77
lib/pages/settings_category_page.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/settings_sections_controller.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
|
||||
class SettingsCategoryPage extends HookConsumerWidget {
|
||||
final int index;
|
||||
final int? initialHighlight;
|
||||
const SettingsCategoryPage(this.index, {this.initialHighlight, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final highlight = useState<int?>(initialHighlight);
|
||||
final listController = useRef(ListController());
|
||||
final scrollController = useScrollController();
|
||||
|
||||
useEffect(() {
|
||||
if (initialHighlight == null) return null;
|
||||
Timer? timer;
|
||||
|
||||
void listener() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!listController.value.isAttached) return;
|
||||
|
||||
listController.value.animateToItem(
|
||||
index: initialHighlight!,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration: (_) => .new(milliseconds: 700),
|
||||
curve: (_) => Curves.easeInOut,
|
||||
);
|
||||
timer = Timer(.new(seconds: 1), () {
|
||||
highlight.value = null;
|
||||
});
|
||||
listController.value.removeListener(listener);
|
||||
});
|
||||
|
||||
listController.value.addListener(listener);
|
||||
return timer?.cancel;
|
||||
}, []);
|
||||
|
||||
return ref
|
||||
.watch(SettingsSectionsController.provider)
|
||||
.betterWhen(
|
||||
data: (sections) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(sections.values.flattenedToList[index].title),
|
||||
),
|
||||
body: SuperListView(
|
||||
controller: scrollController,
|
||||
listController: listController.value,
|
||||
padding: .symmetric(vertical: 12, horizontal: 8),
|
||||
children: sections.values.flattenedToList[index].settings
|
||||
.mapIndexed(
|
||||
(index, setting) => Padding(
|
||||
padding: .only(bottom: 4),
|
||||
child: HighlightWrapper(
|
||||
setting.builder(
|
||||
setting.title,
|
||||
setting.description,
|
||||
setting.icon,
|
||||
),
|
||||
isHighlighted: highlight.value == index,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,236 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:m3e_card_list/m3e_card_list.dart";
|
||||
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
||||
import "package:nexus/controllers/settings_sections_controller.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/show_about_dialog.dart";
|
||||
import "package:nexus/pages/settings_category_page.dart";
|
||||
import "package:nexus/widgets/divider_text.dart";
|
||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Placeholder();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||
builder: (_, constraints) => HookBuilder(
|
||||
builder: (context) {
|
||||
final categoriesArePages = constraints.maxWidth < 550;
|
||||
|
||||
final selected = useState(0);
|
||||
|
||||
final highlightedMatch = useState<(int, int)?>(null);
|
||||
final listController = useRef(ListController());
|
||||
final scrollController = useScrollController();
|
||||
|
||||
final searchBar = SearchAnchor.bar(
|
||||
barHintText: "Search...",
|
||||
suggestionsBuilder: (suggestionsContext, controller) async {
|
||||
final categories = await ref.watch(
|
||||
SettingsSectionsController.provider.future,
|
||||
);
|
||||
final query = controller.text.toLowerCase();
|
||||
|
||||
final matches = categories.values
|
||||
.expand((categoryList) => categoryList.asMap().entries)
|
||||
.expand(
|
||||
(categoryEntry) => categoryEntry.value.settings
|
||||
.asMap()
|
||||
.entries
|
||||
.where(
|
||||
(settingEntry) =>
|
||||
settingEntry.value.title.toLowerCase().contains(
|
||||
query,
|
||||
) ||
|
||||
settingEntry.value.description
|
||||
.toLowerCase()
|
||||
.contains(query),
|
||||
)
|
||||
.map(
|
||||
(settingEntry) => (
|
||||
(categoryEntry.key, settingEntry.key),
|
||||
settingEntry.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
return matches.map(
|
||||
(match) => ListTile(
|
||||
onTap: () async {
|
||||
if (context.mounted) Navigator.of(suggestionsContext).pop();
|
||||
controller.text = "";
|
||||
|
||||
if (categoriesArePages) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsCategoryPage(
|
||||
match.$1.$1,
|
||||
initialHighlight: match.$1.$2,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected.value = match.$1.$1;
|
||||
listController.value.animateToItem(
|
||||
index: match.$1.$2,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration: (_) => .new(milliseconds: 700),
|
||||
curve: (_) => Curves.easeInOut,
|
||||
);
|
||||
highlightedMatch.value = match.$1;
|
||||
await Future.delayed(.new(seconds: 1), () {
|
||||
if (highlightedMatch.value == match.$1) {
|
||||
highlightedMatch.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
leading: Icon(match.$2.icon),
|
||||
title: Text(match.$2.title),
|
||||
subtitle: Text(match.$2.description),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final settingsContent = Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Settings"),
|
||||
actionsPadding: .symmetric(horizontal: 12),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.showAboutDialog(ref),
|
||||
icon: Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ref
|
||||
.watch(SettingsSectionsController.provider)
|
||||
.betterWhen(
|
||||
data: (sections) => categoriesArePages
|
||||
? CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12).copyWith(bottom: 8),
|
||||
child: searchBar,
|
||||
),
|
||||
),
|
||||
...sections
|
||||
.mapTo(
|
||||
(categoryGroup, categories) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
).copyWith(bottom: 4),
|
||||
child: DividerText(categoryGroup),
|
||||
),
|
||||
),
|
||||
SliverM3ECardList(
|
||||
padding: .symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 8,
|
||||
),
|
||||
margin: .symmetric(horizontal: 12),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
itemCount: categories.length,
|
||||
onTap: (index) =>
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
SettingsCategoryPage(index),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
leading: Icon(categories[index].icon),
|
||||
title: Text(categories[index].title),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
.flattened,
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
NavigationRailM3E(
|
||||
type: .alwaysExpand,
|
||||
trailing: searchBar,
|
||||
scrollable: true,
|
||||
sections: sections
|
||||
.mapTo(
|
||||
(categoryGroup, categories) =>
|
||||
NavigationRailM3ESection(
|
||||
header: DividerText(categoryGroup),
|
||||
destinations: categories
|
||||
.map(
|
||||
(category) =>
|
||||
NavigationRailM3EDestination(
|
||||
icon: Icon(category.icon),
|
||||
label: category.title,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selectedIndex: selected.value,
|
||||
onDestinationSelected: (value) =>
|
||||
selected.value = value,
|
||||
),
|
||||
VerticalDivider(),
|
||||
Expanded(
|
||||
child: SuperListView(
|
||||
listController: listController.value,
|
||||
controller: scrollController,
|
||||
padding: .symmetric(vertical: 12),
|
||||
children: sections
|
||||
.values
|
||||
.flattenedToList[selected.value]
|
||||
.settings
|
||||
.mapIndexed(
|
||||
(index, setting) => Padding(
|
||||
padding: .only(bottom: 4),
|
||||
child: HighlightWrapper(
|
||||
setting.builder(
|
||||
setting.title,
|
||||
setting.description,
|
||||
setting.icon,
|
||||
),
|
||||
isHighlighted:
|
||||
highlightedMatch.value ==
|
||||
(selected.value, index),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return constraints.maxWidth < 650
|
||||
? settingsContent
|
||||
: Dialog(
|
||||
constraints: .loose(Size(900, 600)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadiusGeometry.circular(12),
|
||||
child: settingsContent,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import "dart:io";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/settings_controller.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
|
||||
class Appbar extends StatelessWidget implements PreferredSizeWidget {
|
||||
class Appbar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final Widget? leading;
|
||||
final Widget? title;
|
||||
final Color? backgroundColor;
|
||||
|
|
@ -25,7 +27,7 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
|
|||
Size get preferredSize => const .fromHeight(kToolbarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Future<void> maximize() async {
|
||||
final isMaximized = await windowManager.isMaximized();
|
||||
|
||||
|
|
@ -37,7 +39,13 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
|
|||
}
|
||||
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => windowManager.startDragging(),
|
||||
onPanStart: ref
|
||||
.watch(SettingsController.provider)
|
||||
.whenOrNull(
|
||||
data: (settings) => settings.linuxMobileMode
|
||||
? null
|
||||
: (_) => windowManager.startDragging(),
|
||||
),
|
||||
child: AppBar(
|
||||
leading: InkWell(onTap: onTap, child: leading),
|
||||
backgroundColor: backgroundColor,
|
||||
|
|
|
|||
67
lib/widgets/settings/dialog_list_tile.dart
Normal file
67
lib/widgets/settings/dialog_list_tile.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/widgets/settings/radio_dialog.dart";
|
||||
|
||||
class DialogListTile<T> extends ConsumerWidget {
|
||||
final T? initialValue;
|
||||
final String title;
|
||||
final Widget? subtitle;
|
||||
final List<T> options;
|
||||
final bool required;
|
||||
final Icon icon;
|
||||
final void Function(T value)? onChanged;
|
||||
final String Function(T option) getName;
|
||||
const DialogListTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.initialValue,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
required this.getName,
|
||||
this.subtitle,
|
||||
this.required = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => FormField(
|
||||
validator: (value) =>
|
||||
value == null && required == true ? "This field is required." : null,
|
||||
initialValue: initialValue,
|
||||
builder: (field) => InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
errorText: field.errorText,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabledBorder: InputBorder.none,
|
||||
),
|
||||
child: ListTile(
|
||||
enabled: onChanged != null,
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => RadioDialog<T>(
|
||||
title: title,
|
||||
getName: getName,
|
||||
onChanged: onChanged == null
|
||||
? null
|
||||
: (value) {
|
||||
field.didChange(value);
|
||||
onChanged!.call(value);
|
||||
},
|
||||
value: field.value,
|
||||
options: options,
|
||||
),
|
||||
),
|
||||
title: Text(title),
|
||||
subtitle: subtitle,
|
||||
leading: icon,
|
||||
trailing: Chip(
|
||||
label: Text(
|
||||
field.value == null ? "None" : getName(field.value as T),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: onChanged == null ? .new(color: Colors.grey) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
55
lib/widgets/settings/radio_dialog.dart
Normal file
55
lib/widgets/settings/radio_dialog.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
class RadioDialog<T> extends HookWidget {
|
||||
final T? value;
|
||||
final String title;
|
||||
final List<T> options;
|
||||
final void Function(T value)? onChanged;
|
||||
final String Function(T option) getName;
|
||||
const RadioDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
required this.getName,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mutValue = useState<T?>(null);
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: RadioGroup<T>(
|
||||
groupValue: mutValue.value ?? value,
|
||||
onChanged: (value) => mutValue.value = value ?? mutValue.value,
|
||||
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options
|
||||
.map(
|
||||
(option) => RadioListTile<T>(
|
||||
enabled: onChanged != null,
|
||||
value: option,
|
||||
title: Text(getName(option)),
|
||||
dense: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
if (onChanged != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (mutValue.value != null) onChanged!(mutValue.value as T);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text("OK"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
|||
import "package:nexus/controllers/key_controller.dart";
|
||||
import "package:nexus/controllers/spaces_controller.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:nexus/pages/settings_page.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/divider_widget.dart";
|
||||
import "package:nexus/widgets/join_dialog.dart";
|
||||
|
|
@ -181,10 +182,10 @@ class Sidebar extends HookConsumerWidget {
|
|||
),
|
||||
IconButton(
|
||||
tooltip: "Open settings",
|
||||
onPressed: null,
|
||||
// () => Navigator.of(
|
||||
// context,
|
||||
// ).push(MaterialPageRoute(builder: (_) => SettingsPage())),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => SettingsPage(),
|
||||
),
|
||||
icon: Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -953,7 +953,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
|
|
@ -1646,7 +1646,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.5.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ version: 0.1.0
|
|||
publish_to: none
|
||||
|
||||
flutter:
|
||||
fonts:
|
||||
- family: fallback-emoji
|
||||
fonts:
|
||||
- asset: assets/fonts/NotoColorEmoji.ttf
|
||||
- family: fallback-sans
|
||||
fonts:
|
||||
- asset: assets/fonts/Roboto.ttf
|
||||
assets:
|
||||
- assets/
|
||||
uses-material-design: true
|
||||
|
|
@ -67,6 +74,8 @@ dependencies:
|
|||
url: https://github.com/Henry-Hiles/material_3_expressive
|
||||
path: packages/navigation_rail_m3e
|
||||
m3e_card_list: ^0.1.0
|
||||
xdg_directories: ^1.1.0
|
||||
package_info_plus: ^9.0.1
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: 2.15.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue