✨ Better theme & background image
This commit is contained in:
parent
fcf4dc7a2d
commit
f5fbe1f483
@ -471,5 +471,11 @@
|
||||
"accountNav": "You",
|
||||
"performance": "Performance",
|
||||
"animatedMessageList": "Non-animated message list",
|
||||
"animatedMessageListDesc": "Remove animation effects in message list, to reduce cause lag"
|
||||
"animatedMessageListDesc": "Remove animation effects in message list, to reduce cause lag",
|
||||
"theme": "Theme",
|
||||
"globalTheme": "Global theme",
|
||||
"agedTheme": "Old school style theme",
|
||||
"agedThemeDesc": "Downgrade the global theme to Material Design 2. Unexpected issues may occur. For experimental use only.",
|
||||
"appBackgroundImage": "Global background image",
|
||||
"appBackgroundImageDesc": "The global background image will be displayed on all pages"
|
||||
}
|
||||
|
@ -467,5 +467,11 @@
|
||||
"accountNav": "您",
|
||||
"performance": "性能",
|
||||
"animatedMessageList": "无动画消息列表",
|
||||
"animatedMessageListDesc": "在消息列表中禁用动画效果"
|
||||
"animatedMessageListDesc": "在消息列表中禁用动画效果",
|
||||
"theme": "主题",
|
||||
"globalTheme": "全局应用主题",
|
||||
"agedTheme": "过时主题",
|
||||
"agedThemeDesc": "将全局主题降级为 Material Design 2,可能发生意料之外的问题,仅供实验使用",
|
||||
"appBackgroundImage": "全局背景图片",
|
||||
"appBackgroundImageDesc": "全局背景图片将会在所有页面中展示"
|
||||
}
|
||||
|
50
lib/models/theme.dart
Normal file
50
lib/models/theme.dart
Normal file
@ -0,0 +1,50 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'theme.g.dart';
|
||||
|
||||
@JsonSerializable(converters: [ColorConverter()])
|
||||
class SolianThemeData {
|
||||
String id;
|
||||
Color seedColor;
|
||||
String? fontFamily;
|
||||
List<String>? fontFamilyFallback;
|
||||
|
||||
SolianThemeData({
|
||||
required this.id,
|
||||
required this.seedColor,
|
||||
this.fontFamily,
|
||||
this.fontFamilyFallback,
|
||||
});
|
||||
|
||||
factory SolianThemeData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SolianThemeDataFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SolianThemeDataToJson(this);
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is SolianThemeData) {
|
||||
return id == other.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class ColorConverter extends JsonConverter<Color, int> {
|
||||
const ColorConverter();
|
||||
|
||||
@override
|
||||
Color fromJson(int json) {
|
||||
return Color(json);
|
||||
}
|
||||
|
||||
@override
|
||||
int toJson(Color object) {
|
||||
return object.value;
|
||||
}
|
||||
}
|
26
lib/models/theme.g.dart
Normal file
26
lib/models/theme.g.dart
Normal file
@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'theme.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SolianThemeData _$SolianThemeDataFromJson(Map<String, dynamic> json) =>
|
||||
SolianThemeData(
|
||||
id: json['id'] as String,
|
||||
seedColor:
|
||||
const ColorConverter().fromJson((json['seed_color'] as num).toInt()),
|
||||
fontFamily: json['font_family'] as String?,
|
||||
fontFamilyFallback: (json['font_family_fallback'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SolianThemeDataToJson(SolianThemeData instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'seed_color': const ColorConverter().toJson(instance.seedColor),
|
||||
'font_family': instance.fontFamily,
|
||||
'font_family_fallback': instance.fontFamilyFallback,
|
||||
};
|
@ -1,5 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:solian/models/theme.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
|
||||
class ThemeSwitcher extends ChangeNotifier {
|
||||
@ -13,11 +16,21 @@ class ThemeSwitcher extends ChangeNotifier {
|
||||
|
||||
Future<void> restoreTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.containsKey('global_theme_color')) {
|
||||
final value = prefs.getInt('global_theme_color')!;
|
||||
final color = Color(value);
|
||||
lightThemeData = AppTheme.build(Brightness.light, seedColor: color);
|
||||
darkThemeData = AppTheme.build(Brightness.dark, seedColor: color);
|
||||
if (prefs.containsKey('global_theme')) {
|
||||
final value = SolianThemeData.fromJson(
|
||||
jsonDecode(prefs.getString('global_theme')!),
|
||||
);
|
||||
final agedTheme = prefs.getBool('aged_theme');
|
||||
lightThemeData = AppTheme.buildFromData(
|
||||
Brightness.light,
|
||||
value,
|
||||
useMaterial3: agedTheme == null ? true : !agedTheme,
|
||||
);
|
||||
darkThemeData = AppTheme.buildFromData(
|
||||
Brightness.dark,
|
||||
value,
|
||||
useMaterial3: agedTheme == null ? true : !agedTheme,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -27,4 +40,25 @@ class ThemeSwitcher extends ChangeNotifier {
|
||||
darkThemeData = dark;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setThemeData(SolianThemeData? data) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (data == null) {
|
||||
prefs.remove('global_theme');
|
||||
} else {
|
||||
prefs.setString(
|
||||
'global_theme',
|
||||
jsonEncode(data.toJson()),
|
||||
);
|
||||
lightThemeData = AppTheme.buildFromData(Brightness.light, data);
|
||||
darkThemeData = AppTheme.buildFromData(Brightness.dark, data);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAgedTheme(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setBool('aged_theme', enabled);
|
||||
await restoreTheme();
|
||||
}
|
||||
}
|
||||
|
@ -140,6 +140,7 @@ class _ChatListState extends State<ChatList> {
|
||||
return Obx(
|
||||
() => DefaultTabController(
|
||||
length: 2 + realms.availableRealms.length,
|
||||
child: RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Obx(() {
|
||||
@ -165,7 +166,8 @@ class _ChatListState extends State<ChatList> {
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeCommon'.tr),
|
||||
leading: const Icon(Icons.tag),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
AppRouter.instance.pushNamed('channelOrganizing').then(
|
||||
@ -184,7 +186,8 @@ class _ChatListState extends State<ChatList> {
|
||||
FontAwesomeIcons.userGroup,
|
||||
size: 16,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
final ChannelProvider channels = Get.find();
|
||||
@ -216,7 +219,8 @@ class _ChatListState extends State<ChatList> {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(
|
||||
Icons.forum,
|
||||
size: 16,
|
||||
@ -317,6 +321,7 @@ class _ChatListState extends State<ChatList> {
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/realms/realm_organize.dart';
|
||||
import 'package:solian/widgets/realms/realm_deletion.dart';
|
||||
import 'package:solian/widgets/realms/realm_member.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class RealmDetailScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
@ -86,7 +87,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
return RootContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
@ -141,6 +143,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,23 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/theme.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/database/database.dart';
|
||||
import 'package:solian/providers/theme_switcher.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/reports/abuse_report.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
@ -23,6 +30,7 @@ class SettingScreen extends StatefulWidget {
|
||||
|
||||
class _SettingScreenState extends State<SettingScreen> {
|
||||
SharedPreferences? _prefs;
|
||||
String _docBasepath = '/';
|
||||
|
||||
Widget _buildCaptionHeader(String title) {
|
||||
return Container(
|
||||
@ -33,39 +41,38 @@ class _SettingScreenState extends State<SettingScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeColorButton(String label, Color color) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.circle, color: color),
|
||||
tooltip: label,
|
||||
onPressed: () {
|
||||
context.read<ThemeSwitcher>().setTheme(
|
||||
AppTheme.build(
|
||||
Brightness.light,
|
||||
seedColor: color,
|
||||
static final List<SolianThemeData> _presentTheme = [
|
||||
SolianThemeData(
|
||||
id: 'themeColorRed',
|
||||
seedColor: const Color.fromRGBO(154, 98, 91, 1),
|
||||
),
|
||||
AppTheme.build(
|
||||
Brightness.dark,
|
||||
seedColor: color,
|
||||
SolianThemeData(
|
||||
id: 'themeColorBlue',
|
||||
seedColor: const Color.fromRGBO(103, 96, 193, 1),
|
||||
),
|
||||
SolianThemeData(
|
||||
id: 'themeColorMiku',
|
||||
seedColor: const Color.fromRGBO(56, 120, 126, 1),
|
||||
),
|
||||
SolianThemeData(
|
||||
id: 'themeColorKagamine',
|
||||
seedColor: const Color.fromRGBO(244, 183, 63, 1),
|
||||
),
|
||||
SolianThemeData(
|
||||
id: 'themeColorLuka',
|
||||
seedColor: const Color.fromRGBO(243, 174, 218, 1),
|
||||
),
|
||||
);
|
||||
_prefs?.setInt('global_theme_color', color.value);
|
||||
context.clearSnackbar();
|
||||
context.showSnackbar('themeColorApplied'.tr);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final List<(String, Color)> _presentTheme = [
|
||||
('themeColorRed', const Color.fromRGBO(154, 98, 91, 1)),
|
||||
('themeColorBlue', const Color.fromRGBO(103, 96, 193, 1)),
|
||||
('themeColorMiku', const Color.fromRGBO(56, 120, 126, 1)),
|
||||
('themeColorKagamine', const Color.fromRGBO(244, 183, 63, 1)),
|
||||
('themeColorLuka', const Color.fromRGBO(243, 174, 218, 1)),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
getApplicationDocumentsDirectory().then((dir) {
|
||||
_docBasepath = dir.path;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
SharedPreferences.getInstance().then((inst) {
|
||||
_prefs = inst;
|
||||
if (mounted) {
|
||||
@ -79,15 +86,97 @@ class _SettingScreenState extends State<SettingScreen> {
|
||||
return RootContainer(
|
||||
child: ListView(
|
||||
children: [
|
||||
_buildCaptionHeader('themeColor'.tr),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: _presentTheme
|
||||
.map((x) => _buildThemeColorButton(x.$1, x.$2))
|
||||
_buildCaptionHeader('theme'.tr),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
title: Text('globalTheme'.tr),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<SolianThemeData>(
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
'theme'.tr,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
items: _presentTheme
|
||||
.map((SolianThemeData item) =>
|
||||
DropdownMenuItem<SolianThemeData>(
|
||||
value: item,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.circle, color: item.seedColor),
|
||||
const Gap(8),
|
||||
Text(
|
||||
item.id.tr,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
).paddingSymmetric(horizontal: 12, vertical: 8),
|
||||
value: (_prefs?.containsKey('global_theme') ?? false)
|
||||
? SolianThemeData.fromJson(
|
||||
jsonDecode(_prefs!.getString('global_theme')!),
|
||||
)
|
||||
: null,
|
||||
onChanged: (SolianThemeData? value) {
|
||||
context.read<ThemeSwitcher>().setThemeData(value);
|
||||
setState(() {});
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
height: 40,
|
||||
width: 140,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Icons.military_tech),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
title: Text('agedTheme'.tr),
|
||||
subtitle: Text('agedThemeDesc'.tr),
|
||||
value: _prefs?.getBool('aged_theme') ?? false,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
context.read<ThemeSwitcher>().setAgedTheme(value);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (!PlatformInfo.isWeb)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wallpaper),
|
||||
contentPadding: const EdgeInsets.only(left: 22, right: 31),
|
||||
title: Text('appBackgroundImage'.tr),
|
||||
subtitle: Text('appBackgroundImageDesc'.tr),
|
||||
trailing: File('$_docBasepath/app_background_image').existsSync()
|
||||
? const Icon(Icons.check_box)
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
onTap: () async {
|
||||
if (File('$_docBasepath/app_background_image').existsSync()) {
|
||||
File('$_docBasepath/app_background_image').deleteSync();
|
||||
} else {
|
||||
final image = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
);
|
||||
if (image == null) return;
|
||||
|
||||
await File(image.path)
|
||||
.copy('$_docBasepath/app_background_image');
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
_buildCaptionHeader('notification'.tr),
|
||||
Tooltip(
|
||||
|
@ -5,6 +5,7 @@ import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class TitleShell extends StatelessWidget {
|
||||
final bool showAppBar;
|
||||
@ -26,7 +27,8 @@ class TitleShell extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
assert(state != null || title != null);
|
||||
|
||||
return Scaffold(
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: showAppBar
|
||||
? AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
@ -44,6 +46,7 @@ class TitleShell extends StatelessWidget {
|
||||
)
|
||||
: null,
|
||||
body: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/models/theme.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
|
||||
abstract class AppTheme {
|
||||
@ -38,6 +39,7 @@ abstract class AppTheme {
|
||||
brightness: brightness,
|
||||
seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1),
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
@ -55,4 +57,36 @@ abstract class AppTheme {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData buildFromData(
|
||||
Brightness brightness,
|
||||
SolianThemeData data, {
|
||||
bool useMaterial3 = true,
|
||||
}) {
|
||||
return ThemeData(
|
||||
brightness: brightness,
|
||||
useMaterial3: useMaterial3,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
brightness: brightness,
|
||||
seedColor: data.seedColor,
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
fontFamily: data.fontFamily ?? 'Comfortaa',
|
||||
fontFamilyFallback: data.fontFamilyFallback ??
|
||||
[
|
||||
'NotoSansSC',
|
||||
'NotoSansHK',
|
||||
'NotoSansJP',
|
||||
if (PlatformInfo.isWeb) 'NotoSansEmoji',
|
||||
],
|
||||
typography: Typography.material2021(
|
||||
colorScheme: brightness == Brightness.light
|
||||
? const ColorScheme.light()
|
||||
: const ColorScheme.dark(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
|
||||
class RootContainer extends StatelessWidget {
|
||||
final Widget? child;
|
||||
@ -7,9 +11,38 @@ class RootContainer extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: PlatformInfo.isWeb
|
||||
? Future.value(null)
|
||||
: getApplicationDocumentsDirectory(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final path = '${snapshot.data!.path}/app_background_image';
|
||||
final file = File(path);
|
||||
if (file.existsSync()) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
backgroundBlendMode: BlendMode.darken,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
image: DecorationImage(
|
||||
opacity: 0.5,
|
||||
image: FileImage(file),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user