diff --git a/assets/locales/en_us.json b/assets/locales/en_us.json index 79894a5..2c846a2 100644 --- a/assets/locales/en_us.json +++ b/assets/locales/en_us.json @@ -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" } diff --git a/assets/locales/zh_cn.json b/assets/locales/zh_cn.json index cd41ba5..884fe0a 100644 --- a/assets/locales/zh_cn.json +++ b/assets/locales/zh_cn.json @@ -467,5 +467,11 @@ "accountNav": "您", "performance": "性能", "animatedMessageList": "无动画消息列表", - "animatedMessageListDesc": "在消息列表中禁用动画效果" + "animatedMessageListDesc": "在消息列表中禁用动画效果", + "theme": "主题", + "globalTheme": "全局应用主题", + "agedTheme": "过时主题", + "agedThemeDesc": "将全局主题降级为 Material Design 2,可能发生意料之外的问题,仅供实验使用", + "appBackgroundImage": "全局背景图片", + "appBackgroundImageDesc": "全局背景图片将会在所有页面中展示" } diff --git a/lib/models/theme.dart b/lib/models/theme.dart new file mode 100644 index 0000000..db3e6ba --- /dev/null +++ b/lib/models/theme.dart @@ -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? fontFamilyFallback; + + SolianThemeData({ + required this.id, + required this.seedColor, + this.fontFamily, + this.fontFamilyFallback, + }); + + factory SolianThemeData.fromJson(Map json) => + _$SolianThemeDataFromJson(json); + + Map 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 { + const ColorConverter(); + + @override + Color fromJson(int json) { + return Color(json); + } + + @override + int toJson(Color object) { + return object.value; + } +} diff --git a/lib/models/theme.g.dart b/lib/models/theme.g.dart new file mode 100644 index 0000000..e8f5c9d --- /dev/null +++ b/lib/models/theme.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SolianThemeData _$SolianThemeDataFromJson(Map 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?) + ?.map((e) => e as String) + .toList(), + ); + +Map _$SolianThemeDataToJson(SolianThemeData instance) => + { + 'id': instance.id, + 'seed_color': const ColorConverter().toJson(instance.seedColor), + 'font_family': instance.fontFamily, + 'font_family_fallback': instance.fontFamilyFallback, + }; diff --git a/lib/providers/theme_switcher.dart b/lib/providers/theme_switcher.dart index acc1b71..e1331eb 100644 --- a/lib/providers/theme_switcher.dart +++ b/lib/providers/theme_switcher.dart @@ -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 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 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 setAgedTheme(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + prefs.setBool('aged_theme', enabled); + await restoreTheme(); + } } diff --git a/lib/screens/chat.dart b/lib/screens/chat.dart index 35907c7..8b0d226 100644 --- a/lib/screens/chat.dart +++ b/lib/screens/chat.dart @@ -140,181 +140,186 @@ class _ChatListState extends State { return Obx( () => DefaultTabController( length: 2 + realms.availableRealms.length, - child: Scaffold( - appBar: AppBar( - leading: Obx(() { - final adaptive = AppBarLeadingButton.adaptive(context); - if (adaptive != null) return adaptive; - if (_channels.isLoading.value) { - return const CircularProgressIndicator( - strokeWidth: 3, - ).paddingAll(18); - } - return const SizedBox.shrink(); - }), - title: AppBarTitle('chat'.tr), - centerTitle: true, - toolbarHeight: AppTheme.toolbarHeight(context), - actions: [ - const BackgroundStateWidget(), - const NotificationButton(), - PopupMenuButton( - icon: const Icon(Icons.add_circle), - itemBuilder: (BuildContext context) => [ - PopupMenuItem( - child: ListTile( - title: Text('channelOrganizeCommon'.tr), - leading: const Icon(Icons.tag), - contentPadding: const EdgeInsets.symmetric(horizontal: 8), + child: RootContainer( + child: Scaffold( + appBar: AppBar( + leading: Obx(() { + final adaptive = AppBarLeadingButton.adaptive(context); + if (adaptive != null) return adaptive; + if (_channels.isLoading.value) { + return const CircularProgressIndicator( + strokeWidth: 3, + ).paddingAll(18); + } + return const SizedBox.shrink(); + }), + title: AppBarTitle('chat'.tr), + centerTitle: true, + toolbarHeight: AppTheme.toolbarHeight(context), + actions: [ + const BackgroundStateWidget(), + const NotificationButton(), + PopupMenuButton( + icon: const Icon(Icons.add_circle), + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + child: ListTile( + title: Text('channelOrganizeCommon'.tr), + leading: const Icon(Icons.tag), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8), + ), + onTap: () { + AppRouter.instance.pushNamed('channelOrganizing').then( + (value) { + if (value != null) { + _loadAllChannels(); + } + }, + ); + }, ), - onTap: () { - AppRouter.instance.pushNamed('channelOrganizing').then( - (value) { - if (value != null) { + PopupMenuItem( + child: ListTile( + title: Text('channelOrganizeDirect'.tr), + leading: const FaIcon( + FontAwesomeIcons.userGroup, + size: 16, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8), + ), + onTap: () { + final ChannelProvider channels = Get.find(); + channels + .createDirectChannel(context, 'global') + .then((resp) { + if (resp != null) { _loadAllChannels(); } - }, - ); - }, - ), - PopupMenuItem( - child: ListTile( - title: Text('channelOrganizeDirect'.tr), - leading: const FaIcon( - FontAwesomeIcons.userGroup, - size: 16, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 8), + }).catchError((e) { + context.showErrorDialog(e); + }); + }, + ), + ], + ), + SizedBox( + width: AppTheme.isLargeScreen(context) ? 8 : 16, + ), + ], + bottom: TabBar( + isScrollable: true, + dividerHeight: 0.3, + tabAlignment: TabAlignment.startOffset, + tabs: [ + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: + Theme.of(context).colorScheme.primary, + child: const Icon( + Icons.forum, + size: 16, + color: Colors.white, + ), + ), + const Gap(8), + Text('all'.tr), + ], ), - onTap: () { - final ChannelProvider channels = Get.find(); - channels - .createDirectChannel(context, 'global') - .then((resp) { - if (resp != null) { - _loadAllChannels(); - } - }).catchError((e) { - context.showErrorDialog(e); - }); - }, ), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const CircleAvatar( + radius: 14, + child: Icon( + Icons.chat_bubble, + size: 16, + ), + ), + const Gap(8), + Text('channelTypeDirect'.tr), + ], + ), + ), + ...realms.availableRealms.map((x) => Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AccountAvatar( + content: x.avatar, + radius: 14, + fallbackWidget: const Icon( + Icons.workspaces, + size: 16, + ), + ), + const Gap(8), + Text(x.name), + ], + ), + )), ], ), - SizedBox( - width: AppTheme.isLargeScreen(context) ? 8 : 16, - ), - ], - bottom: TabBar( - isScrollable: true, - dividerHeight: 0.3, - tabAlignment: TabAlignment.startOffset, - tabs: [ - Tab( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar( - radius: 14, - backgroundColor: Theme.of(context).colorScheme.primary, - child: const Icon( - Icons.forum, - size: 16, - color: Colors.white, - ), - ), - const Gap(8), - Text('all'.tr), - ], - ), - ), - Tab( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const CircleAvatar( - radius: 14, - child: Icon( - Icons.chat_bubble, - size: 16, - ), - ), - const Gap(8), - Text('channelTypeDirect'.tr), - ], - ), - ), - ...realms.availableRealms.map((x) => Tab( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AccountAvatar( - content: x.avatar, - radius: 14, - fallbackWidget: const Icon( - Icons.workspaces, - size: 16, - ), - ), - const Gap(8), - Text(x.name), - ], - ), - )), - ], ), - ), - body: Obx(() { - if (auth.isAuthorized.isFalse) { - return SigninRequiredOverlay( - onDone: () => _loadAllChannels(), - ); - } + body: Obx(() { + if (auth.isAuthorized.isFalse) { + return SigninRequiredOverlay( + onDone: () => _loadAllChannels(), + ); + } - final selfId = auth.userProfile.value!['id']; + final selfId = auth.userProfile.value!['id']; - return Column( - children: [ - const ChatCallCurrentIndicator(), - Expanded( - child: TabBarView( - children: [ - RefreshIndicator( - onRefresh: _loadNormalChannels, - child: ChannelListWidget( - channels: _sortChannels([ - ..._normalChannels, - ..._directChannels, - ..._realmChannels.values.expand((x) => x), - ]), - selfId: selfId, - useReplace: AppTheme.isLargeScreen(context), - ), - ), - RefreshIndicator( - onRefresh: _loadDirectChannels, - child: ChannelListWidget( - channels: _directChannels, - selfId: selfId, - useReplace: AppTheme.isLargeScreen(context), - ), - ), - ...realms.availableRealms.map( - (x) => RefreshIndicator( - onRefresh: () => _loadRealmChannels(x.alias), + return Column( + children: [ + const ChatCallCurrentIndicator(), + Expanded( + child: TabBarView( + children: [ + RefreshIndicator( + onRefresh: _loadNormalChannels, child: ChannelListWidget( - channels: _realmChannels[x.alias] ?? [], + channels: _sortChannels([ + ..._normalChannels, + ..._directChannels, + ..._realmChannels.values.expand((x) => x), + ]), selfId: selfId, useReplace: AppTheme.isLargeScreen(context), ), ), - ), - ], + RefreshIndicator( + onRefresh: _loadDirectChannels, + child: ChannelListWidget( + channels: _directChannels, + selfId: selfId, + useReplace: AppTheme.isLargeScreen(context), + ), + ), + ...realms.availableRealms.map( + (x) => RefreshIndicator( + onRefresh: () => _loadRealmChannels(x.alias), + child: ChannelListWidget( + channels: _realmChannels[x.alias] ?? [], + selfId: selfId, + useReplace: AppTheme.isLargeScreen(context), + ), + ), + ), + ], + ), ), - ), - ], - ); - }), + ], + ); + }), + ), ), ), ); diff --git a/lib/screens/realms/realm_detail.dart b/lib/screens/realms/realm_detail.dart index 7132e41..48f2664 100644 --- a/lib/screens/realms/realm_detail.dart +++ b/lib/screens/realms/realm_detail.dart @@ -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,61 +87,63 @@ class _RealmDetailScreenState extends State { ), ]; - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - const CircleAvatar( - radius: 28, - backgroundColor: Colors.teal, - child: Icon(Icons.group, color: Colors.white), - ), - const Gap(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.realm.name, - style: Theme.of(context).textTheme.bodyLarge), - Text(widget.realm.description, - style: Theme.of(context).textTheme.bodySmall), - Text( - '#${widget.realm.id.toString().padLeft(8, '0')} · ${widget.realm.alias}', - style: const TextStyle(fontSize: 11), - ), - ], + return RootContainer( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const CircleAvatar( + radius: 28, + backgroundColor: Colors.teal, + child: Icon(Icons.group, color: Colors.white), ), - ) - ], + const Gap(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.realm.name, + style: Theme.of(context).textTheme.bodyLarge), + Text(widget.realm.description, + style: Theme.of(context).textTheme.bodySmall), + Text( + '#${widget.realm.id.toString().padLeft(8, '0')} · ${widget.realm.alias}', + style: const TextStyle(fontSize: 11), + ), + ], + ), + ) + ], + ), ), - ), - const Divider(thickness: 0.3), - Expanded( - child: ListView( - children: [ - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: const Icon(Icons.supervisor_account), - trailing: const Icon(Icons.chevron_right), - title: Text('realmMembers'.tr), - onTap: () => showMemberList(), - ), - ...(_isOwned ? ownerActions : List.empty()), - const Divider(thickness: 0.3), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: _isOwned - ? const Icon(Icons.delete) - : const Icon(Icons.exit_to_app), - title: Text(_isOwned ? 'delete'.tr : 'leave'.tr), - onTap: () => promptLeaveChannel(), - ), - ], + const Divider(thickness: 0.3), + Expanded( + child: ListView( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Icons.supervisor_account), + trailing: const Icon(Icons.chevron_right), + title: Text('realmMembers'.tr), + onTap: () => showMemberList(), + ), + ...(_isOwned ? ownerActions : List.empty()), + const Divider(thickness: 0.3), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: _isOwned + ? const Icon(Icons.delete) + : const Icon(Icons.exit_to_app), + title: Text(_isOwned ? 'delete'.tr : 'leave'.tr), + onTap: () => promptLeaveChannel(), + ), + ], + ), ), - ), - ], + ], + ), ); } } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 76d50a7..0e33bc3 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -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 { SharedPreferences? _prefs; + String _docBasepath = '/'; Widget _buildCaptionHeader(String title) { return Container( @@ -33,39 +41,38 @@ class _SettingScreenState extends State { ); } - Widget _buildThemeColorButton(String label, Color color) { - return IconButton( - icon: Icon(Icons.circle, color: color), - tooltip: label, - onPressed: () { - context.read().setTheme( - AppTheme.build( - Brightness.light, - seedColor: color, - ), - AppTheme.build( - Brightness.dark, - seedColor: color, - ), - ); - _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)), + static final List _presentTheme = [ + SolianThemeData( + id: 'themeColorRed', + seedColor: const Color.fromRGBO(154, 98, 91, 1), + ), + 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), + ), ]; @override void initState() { super.initState(); + getApplicationDocumentsDirectory().then((dir) { + _docBasepath = dir.path; + if (mounted) { + setState(() {}); + } + }); SharedPreferences.getInstance().then((inst) { _prefs = inst; if (mounted) { @@ -79,16 +86,98 @@ class _SettingScreenState extends State { 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)) - .toList(), - ).paddingSymmetric(horizontal: 12, vertical: 8), + _buildCaptionHeader('theme'.tr), + ListTile( + leading: const Icon(Icons.palette), + contentPadding: const EdgeInsets.symmetric(horizontal: 22), + title: Text('globalTheme'.tr), + trailing: DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: Text( + 'theme'.tr, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ), + items: _presentTheme + .map((SolianThemeData item) => + DropdownMenuItem( + 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(), + value: (_prefs?.containsKey('global_theme') ?? false) + ? SolianThemeData.fromJson( + jsonDecode(_prefs!.getString('global_theme')!), + ) + : null, + onChanged: (SolianThemeData? value) { + context.read().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().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( message: 'settingsNotificationBgServiceDesc'.tr, diff --git a/lib/shells/title_shell.dart b/lib/shells/title_shell.dart index 5e7cbc0..c03d6ee 100644 --- a/lib/shells/title_shell.dart +++ b/lib/shells/title_shell.dart @@ -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,24 +27,26 @@ class TitleShell extends StatelessWidget { Widget build(BuildContext context) { assert(state != null || title != null); - return Scaffold( - appBar: showAppBar - ? AppBar( - leading: AppBarLeadingButton.adaptive(context), - title: AppBarTitle( - title ?? (state!.topRoute?.name?.tr ?? 'page'.tr), - ), - centerTitle: isCenteredTitle, - toolbarHeight: AppTheme.toolbarHeight(context), - actions: [ - const BackgroundStateWidget(), - SizedBox( - width: AppTheme.isLargeScreen(context) ? 8 : 16, + return RootContainer( + child: Scaffold( + appBar: showAppBar + ? AppBar( + leading: AppBarLeadingButton.adaptive(context), + title: AppBarTitle( + title ?? (state!.topRoute?.name?.tr ?? 'page'.tr), ), - ], - ) - : null, - body: child, + centerTitle: isCenteredTitle, + toolbarHeight: AppTheme.toolbarHeight(context), + actions: [ + const BackgroundStateWidget(), + SizedBox( + width: AppTheme.isLargeScreen(context) ? 8 : 16, + ), + ], + ) + : null, + body: child, + ), ); } } diff --git a/lib/theme.dart b/lib/theme.dart index daa95a4..cd82c34 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -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(), + ), + ); + } } diff --git a/lib/widgets/root_container.dart b/lib/widgets/root_container.dart index 3a6ae80..36d8f53 100644 --- a/lib/widgets/root_container.dart +++ b/lib/widgets/root_container.dart @@ -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 Material( - color: Theme.of(context).colorScheme.surface, - child: child, + 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, + ); + }, ); } }