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,
|
backgroundColor: surfaceContainerLow,
|
||||||
),
|
),
|
||||||
textTheme: ThemeData(
|
textTheme: ThemeData(
|
||||||
fontFamilyFallback: ["sans", "emoji"],
|
fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"],
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
).textTheme,
|
).textTheme,
|
||||||
inputDecorationTheme: const InputDecorationTheme(
|
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/client_state_controller.dart";
|
||||||
import "package:nexus/controllers/header_controller.dart";
|
import "package:nexus/controllers/header_controller.dart";
|
||||||
import "package:nexus/controllers/multi_provider_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/controllers/shared_prefs_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
import "package:nexus/helpers/extensions/scheme_to_theme.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/chat_page.dart";
|
||||||
import "package:nexus/pages/select_server_page.dart";
|
import "package:nexus/pages/select_server_page.dart";
|
||||||
import "package:nexus/pages/verify_page.dart";
|
import "package:nexus/pages/verify_page.dart";
|
||||||
|
|
@ -70,6 +72,8 @@ void main() async {
|
||||||
await windowManager.setMinimumSize(Size.square(500));
|
await windowManager.setMinimumSize(Size.square(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses));
|
||||||
|
|
||||||
FlutterError.onError = (FlutterErrorDetails details) =>
|
FlutterError.onError = (FlutterErrorDetails details) =>
|
||||||
showError(details.exception.toString(), details.stack);
|
showError(details.exception.toString(), details.stack);
|
||||||
|
|
||||||
|
|
@ -90,20 +94,43 @@ class App extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => DynamicColorBuilder(
|
Widget build(BuildContext context) => DynamicColorBuilder(
|
||||||
builder: (lightDynamic, darkDynamic) => MaterialApp(
|
builder: (lightDynamic, darkDynamic) => Consumer(
|
||||||
|
builder: (context, ref, child) => MaterialApp(
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
// Use indigo to work around bugs in theme generation
|
// Use indigo to work around bugs in theme generation
|
||||||
theme: (lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo))
|
theme:
|
||||||
|
(ref
|
||||||
|
.watch(SettingsController.provider)
|
||||||
|
.maybeWhen(
|
||||||
|
orElse: () => lightDynamic,
|
||||||
|
data: (settings) =>
|
||||||
|
settings.useDynamicTheming ? lightDynamic : null,
|
||||||
|
) ??
|
||||||
|
ColorScheme.fromSeed(seedColor: Colors.indigo))
|
||||||
.theme,
|
.theme,
|
||||||
darkTheme:
|
darkTheme:
|
||||||
(darkDynamic ??
|
(ref
|
||||||
|
.watch(SettingsController.provider)
|
||||||
|
.maybeWhen(
|
||||||
|
orElse: () => darkDynamic,
|
||||||
|
data: (settings) =>
|
||||||
|
settings.useDynamicTheming ? darkDynamic : null,
|
||||||
|
) ??
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
seedColor: Colors.indigo,
|
seedColor: Colors.indigo,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
))
|
))
|
||||||
.theme,
|
.theme,
|
||||||
home: Scaffold(
|
themeMode: ref
|
||||||
|
.watch(SettingsController.provider)
|
||||||
|
.maybeWhen(
|
||||||
|
data: (settings) => settings.theme,
|
||||||
|
orElse: () => ThemeMode.system,
|
||||||
|
),
|
||||||
|
home: child,
|
||||||
|
),
|
||||||
|
child: Scaffold(
|
||||||
body: Consumer(
|
body: Consumer(
|
||||||
builder: (_, ref, _) => ref
|
builder: (_, ref, _) => ref
|
||||||
.watch(
|
.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/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 {
|
class SettingsPage extends ConsumerWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||||
return Placeholder();
|
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 "dart:io";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter/material.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";
|
import "package:window_manager/window_manager.dart";
|
||||||
|
|
||||||
class Appbar extends StatelessWidget implements PreferredSizeWidget {
|
class Appbar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
final Widget? title;
|
final Widget? title;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
|
|
@ -25,7 +27,7 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
Size get preferredSize => const .fromHeight(kToolbarHeight);
|
Size get preferredSize => const .fromHeight(kToolbarHeight);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
Future<void> maximize() async {
|
Future<void> maximize() async {
|
||||||
final isMaximized = await windowManager.isMaximized();
|
final isMaximized = await windowManager.isMaximized();
|
||||||
|
|
||||||
|
|
@ -37,7 +39,13 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onPanStart: (_) => windowManager.startDragging(),
|
onPanStart: ref
|
||||||
|
.watch(SettingsController.provider)
|
||||||
|
.whenOrNull(
|
||||||
|
data: (settings) => settings.linuxMobileMode
|
||||||
|
? null
|
||||||
|
: (_) => windowManager.startDragging(),
|
||||||
|
),
|
||||||
child: AppBar(
|
child: AppBar(
|
||||||
leading: InkWell(onTap: onTap, child: leading),
|
leading: InkWell(onTap: onTap, child: leading),
|
||||||
backgroundColor: backgroundColor,
|
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/key_controller.dart";
|
||||||
import "package:nexus/controllers/spaces_controller.dart";
|
import "package:nexus/controllers/spaces_controller.dart";
|
||||||
import "package:nexus/models/room.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/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/divider_widget.dart";
|
import "package:nexus/widgets/divider_widget.dart";
|
||||||
import "package:nexus/widgets/join_dialog.dart";
|
import "package:nexus/widgets/join_dialog.dart";
|
||||||
|
|
@ -181,10 +182,10 @@ class Sidebar extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Open settings",
|
tooltip: "Open settings",
|
||||||
onPressed: null,
|
onPressed: () => showDialog(
|
||||||
// () => Navigator.of(
|
context: context,
|
||||||
// context,
|
builder: (_) => SettingsPage(),
|
||||||
// ).push(MaterialPageRoute(builder: (_) => SettingsPage())),
|
),
|
||||||
icon: Icon(Icons.settings),
|
icon: Icon(Icons.settings),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -953,7 +953,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||||
|
|
@ -1646,7 +1646,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "0.5.1"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: xdg_directories
|
name: xdg_directories
|
||||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ version: 0.1.0
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
|
fonts:
|
||||||
|
- family: fallback-emoji
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/NotoColorEmoji.ttf
|
||||||
|
- family: fallback-sans
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/Roboto.ttf
|
||||||
assets:
|
assets:
|
||||||
- assets/
|
- assets/
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
@ -67,6 +74,8 @@ dependencies:
|
||||||
url: https://github.com/Henry-Hiles/material_3_expressive
|
url: https://github.com/Henry-Hiles/material_3_expressive
|
||||||
path: packages/navigation_rail_m3e
|
path: packages/navigation_rail_m3e
|
||||||
m3e_card_list: ^0.1.0
|
m3e_card_list: ^0.1.0
|
||||||
|
xdg_directories: ^1.1.0
|
||||||
|
package_info_plus: ^9.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: 2.15.0
|
build_runner: 2.15.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue