From 52da87a159b96d9a2f0fe20a727320968f4c050a Mon Sep 17 00:00:00 2001 From: realth000 Date: Sat, 5 Oct 2024 02:32:58 +0800 Subject: [PATCH] feat(*): remembering window size and position on desktop platforms --- CHANGELOG.md | 4 + lib/app.dart | 78 ++++++++++++++++++- lib/constants/layout.dart | 6 ++ .../repositories/settings_repository.dart | 8 +- lib/features/settings/view/settings_page.dart | 47 +++++++++++ lib/i18n/strings.i18n.json | 16 ++++ lib/i18n/strings_zh-CN.i18n.json | 16 ++++ lib/i18n/strings_zh-TW.i18n.json | 16 ++++ lib/main.dart | 27 ++++--- lib/shared/models/settings.dart | 49 +++++++----- lib/shared/models/settings_map.dart | 32 ++++---- .../models/convertable/offset.dart | 5 +- .../models/convertable/size.dart | 5 +- 13 files changed, 254 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c579ac..f4899163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - app:现在在头像加载失败时会使用本地默认的头像,避免头像一直空白。 - app:现在加载失败的头像更容易触发重新加载。 - app:桌面平台上设置窗口标题。 +- app:桌面平台上支持记住窗口大小和位置。 + - 默认开启,可在设置 -> 窗口中关闭。 +- app: 桌面平台支持窗口居中。 + - 默认关闭,可在设置 -> 窗口中开启。 - 登录:在登录界面显示注册账户的跳转链接。 - 历史:新增帖子浏览记录。 - 记录帖子名称、浏览的用户,帖子所在分区以及浏览时间。 diff --git a/lib/app.dart b/lib/app.dart index e08ff75e..0f3f43dc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -16,12 +18,14 @@ import 'package:tsdm_client/features/upgrade/repository/upgrade_repository.dart' import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/app_routes.dart'; +import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/shared/providers/providers.dart'; import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart'; import 'package:tsdm_client/shared/repositories/fragments_repository/fragments_repository.dart'; import 'package:tsdm_client/themes/app_themes.dart'; +import 'package:tsdm_client/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; /// Main app for tsdm_client. @@ -40,6 +44,66 @@ class App extends StatefulWidget { } class _AppState extends State with WindowListener { + /// Duration used to debounce the frequency to save window attributes into + /// storage. + /// + /// Only save the latest value to storage if in recent duration no more attr + /// changes triggered. + static const _syncDebounceDuration = Duration(milliseconds: 80); + + /// Temporary store of current window position value. + var _windowPosition = Offset.zero; + + /// Temporary store of current window size value. + var _windowSize = Size.zero; + + /// Timer to debounce the saving progress of window position. + /// + /// Save [_windowPosition] to storage when timer timeout. + Timer? windowPositionTimer; + + /// Timer to debounce the saving progress of window size. + /// + /// Save [_windowSize] to storage when timer timeout. + Timer? windowSizeTimer; + + void setupWindowPositionTimer() { + if (windowPositionTimer?.isActive ?? false) { + windowPositionTimer!.cancel(); + } + windowPositionTimer = Timer(_syncDebounceDuration, () async { + talker.debug('save window position to $_windowPosition'); + final settings = getIt.get().currentSettings; + if (!settings.windowRememberPosition || settings.windowInCenter) { + // Do nothing if not remembering window position, or window forced in + // center. + return; + } + // FIXME: Access provider in top-level components is anti-pattern. + await getIt + .get() + .saveOffset(SettingsKeys.windowPosition.name, _windowPosition); + }); + } + + void setupWindowSizeTimer() { + if (windowSizeTimer?.isActive ?? false) { + windowSizeTimer!.cancel(); + } + windowSizeTimer = Timer(_syncDebounceDuration, () async { + talker.debug('save window size to $_windowPosition'); + final settings = getIt.get().currentSettings; + if (!settings.windowRememberSize) { + // Do nothing if not remembering window size. + return; + } + // FIXME: Access provider in top-level components is anti-pattern. + await getIt + .get() + .saveSize(SettingsKeys.windowSize.name, _windowSize); + }); + } + @override void initState() { super.initState(); @@ -49,6 +113,8 @@ class _AppState extends State with WindowListener { @override void dispose() { windowManager.removeListener(this); + windowPositionTimer?.cancel(); + windowSizeTimer?.cancel(); super.dispose(); } @@ -136,14 +202,18 @@ class _AppState extends State with WindowListener { @override Future onWindowMove() async { super.onWindowMove(); - final x = await windowManager.getPosition(); - debugPrint('>>> window moved to $x'); + if (isDesktop) { + _windowPosition = await windowManager.getPosition(); + setupWindowPositionTimer(); + } } @override Future onWindowResize() async { super.onWindowResize(); - final x = await windowManager.getSize(); - debugPrint('>>> window size is $x'); + if (isDesktop) { + _windowSize = await windowManager.getSize(); + setupWindowSizeTimer(); + } } } diff --git a/lib/constants/layout.dart b/lib/constants/layout.dart index 0d393e43..8105a2f3 100644 --- a/lib/constants/layout.dart +++ b/lib/constants/layout.dart @@ -99,6 +99,12 @@ const edgeInsetsL24R24 = EdgeInsets.only(left: 24, right: 24); /// An [EdgeInsets] with 24 at left and right, 12 at bottom. const edgeInsetsL24R24B12 = EdgeInsets.only(left: 24, right: 24, bottom: 12); +/// An [EdgeInsets] with 24 at left and right, 16 at top and bottom. +const edgeInsetsL24T12R24B12 = EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, +); + /// An [EdgeInsets] with 12 at left, 4 at top and 4 at bottom. const edgeInsetsL12T4R4B4 = EdgeInsets.only(left: 12, top: 4, right: 4, bottom: 4); diff --git a/lib/features/settings/repositories/settings_repository.dart b/lib/features/settings/repositories/settings_repository.dart index 971f6e82..2b0b0dee 100644 --- a/lib/features/settings/repositories/settings_repository.dart +++ b/lib/features/settings/repositories/settings_repository.dart @@ -80,10 +80,10 @@ final class SettingsRepository with LoggerMixin { netClientAcceptEncoding: s.extract(_SK.netClientAcceptEncoding), netClientAcceptLanguage: s.extract(_SK.netClientAcceptLanguage), netClientUserAgent: s.extract(_SK.netClientUserAgent), - windowWidth: s.extract(_SK.windowWidth), - windowHeight: s.extract(_SK.windowHeight), - windowPositionDx: s.extract(_SK.windowPositionDx), - windowPositionDy: s.extract(_SK.windowPositionDy), + windowRememberSize: s.extract(_SK.windowRememberSize), + windowSize: s.extract(_SK.windowSize), + windowRememberPosition: s.extract(_SK.windowRememberPosition), + windowPosition: s.extract(_SK.windowPosition), windowInCenter: s.extract(_SK.windowInCenter), loginUsername: s.extract(_SK.loginUsername), loginUid: s.extract(_SK.loginUid), diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index f6261837..d471d976 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -29,6 +29,7 @@ import 'package:tsdm_client/widgets/color_palette.dart'; import 'package:tsdm_client/widgets/section_list_tile.dart'; import 'package:tsdm_client/widgets/section_switch_list_tile.dart'; import 'package:tsdm_client/widgets/section_title_text.dart'; +import 'package:tsdm_client/widgets/tips.dart'; /// Settings page of the app. class SettingsPage extends StatefulWidget { @@ -303,6 +304,51 @@ class _SettingsPageState extends State { ]; } + /// App window related settings. + /// + /// Only available on desktop platforms. + List _buildWindowSection( + BuildContext context, + SettingsState state, + ) { + final tr = context.t.settingsPage.windowSection; + final windowRememberSize = state.settingsMap.windowRememberSize; + final windowRememberPosition = state.settingsMap.windowRememberPosition; + final windowInCenter = state.settingsMap.windowInCenter; + + return [ + SectionTitleText(tr.title), + SectionSwitchListTile( + secondary: const Icon(Icons.settings_overscan_outlined), + title: Text(tr.windowRememberSize.title), + subtitle: Text(tr.windowRememberSize.detail), + value: windowRememberSize, + onChanged: (v) => context + .read() + .add(SettingsValueChanged(SettingsKeys.windowRememberSize, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.open_with_outlined), + title: Text(tr.windowRememberPosition.title), + subtitle: Text(tr.windowRememberPosition.detail), + value: windowRememberPosition, + onChanged: (v) => context + .read() + .add(SettingsValueChanged(SettingsKeys.windowRememberPosition, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.filter_center_focus_outlined), + title: Text(tr.windowInCenter.title), + subtitle: Text(tr.windowInCenter.detail), + value: windowInCenter, + onChanged: (v) => context + .read() + .add(SettingsValueChanged(SettingsKeys.windowInCenter, v)), + ), + Tips(tr.disableHint), + ]; + } + List _buildBehaviorSection( BuildContext context, SettingsState state, @@ -567,6 +613,7 @@ class _SettingsPageState extends State { controller: scrollController, children: [ ..._buildAppearanceSection(context, state), + if (isDesktop) ..._buildWindowSection(context, state), ..._buildBehaviorSection(context, state), ..._buildCheckinSection(context, state), ..._buildStorageSection(context, state), diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index cc1b7d26..7fbded6a 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -89,6 +89,22 @@ } } }, + "windowSection": { + "title": "Window", + "disableHint": "Add cmdline args \"--no-window-configs\" will disable this settings section, which is useful when window is missing after startup.", + "windowRememberSize": { + "title": "Remember window size", + "detail": "Save window size and apply every time app starts" + }, + "windowRememberPosition": { + "title": "Remember window position", + "detail": "Save window position and apply every time app starts" + }, + "windowInCenter": { + "title": "Window in center", + "detail": "Force the window in the center of screen when app starts" + } + }, "behaviorSection": { "title": "Behavior", "doublePressExit": { diff --git a/lib/i18n/strings_zh-CN.i18n.json b/lib/i18n/strings_zh-CN.i18n.json index 1c551f25..9ff6d614 100644 --- a/lib/i18n/strings_zh-CN.i18n.json +++ b/lib/i18n/strings_zh-CN.i18n.json @@ -89,6 +89,22 @@ } } }, + "windowSection": { + "title": "窗口", + "disableHint": "在启动时增加参数\"--no-window-configs\"将会禁用该部分设置。如果软件开启后看不到窗口,可以尝试增加此参数。", + "windowRememberSize": { + "title": "记住窗口大小", + "detail": "启动时还原上次的窗口大小" + }, + "windowRememberPosition": { + "title": "记住窗口位置", + "detail": "启动时还原上次的窗口位置" + }, + "windowInCenter": { + "title": "窗口居中", + "detail": "启动时强制窗口居中" + } + }, "behaviorSection": { "title": "行为", "doublePressExit": { diff --git a/lib/i18n/strings_zh-TW.i18n.json b/lib/i18n/strings_zh-TW.i18n.json index f03130f0..acefb787 100644 --- a/lib/i18n/strings_zh-TW.i18n.json +++ b/lib/i18n/strings_zh-TW.i18n.json @@ -89,6 +89,22 @@ } } }, + "windowSection": { + "title": "視窗", + "disableHint": "在啟動時增加參數\"--no-window-configs\"將會停用該部分設置,如果軟體開啟後看不到窗口,可以嘗試增加此參數", + "windowRememberSize": { + "title": "記住視窗大小", + "detail": "記錄視窗大小並在每次啟動時套用" + }, + "windowRememberPosition": { + "title": "記住視窗位置", + "detail": "記住視窗位置並在每次啟動時套用" + }, + "windowInCenter": { + "title": "視窗居中", + "detail": "強制視窗劇中,並在每次啟動時套用" + } + }, "behaviorSection": { "title": "行為", "doublePressExit": { diff --git a/lib/main.dart b/lib/main.dart index a193062e..3129c753 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,6 @@ import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; -import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/providers.dart'; import 'package:tsdm_client/utils/platform.dart'; import 'package:tsdm_client/utils/window_configs.dart'; @@ -21,9 +20,9 @@ Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); await initProviders(); - final settings = getIt.get(); + final settings = getIt.get().currentSettings; - final settingsLocale = await settings.getValue(SettingsKeys.locale); + final settingsLocale = settings.locale; final locale = AppLocale.values.firstWhereOrNull((v) => v.languageTag == settingsLocale); if (locale == null) { @@ -32,10 +31,19 @@ Future main(List args) async { LocaleSettings.setLocale(locale); } - if (isDesktop) { - await windowManager.ensureInitialized(); + await windowManager.ensureInitialized(); + + if (isDesktop && !cmdArgs.noWindowConfigs) { await desktopUpdateWindowTitle(); - await windowManager.center(); + if (settings.windowInCenter) { + await windowManager.center(); + } else if (settings.windowRememberPosition && + settings.windowPosition != Offset.zero) { + await windowManager.setPosition(settings.windowPosition); + } + if (settings.windowRememberSize && settings.windowSize != Size.zero) { + await windowManager.setSize(settings.windowSize); + } } // System color. @@ -43,16 +51,15 @@ Future main(List args) async { // // A not empty value represents currently is using system color and the color // value is inside it. - final useSystemTheme = - await settings.getValue(SettingsKeys.accentColorFollowSystem); + final useSystemTheme = settings.accentColorFollowSystem; final color = switch (useSystemTheme) { true => await SystemTheme.accentColor .load() .then((_) => SystemTheme.accentColor.accent.value), - false => await settings.getValue(SettingsKeys.accentColor), + false => settings.accentColor, }; - final themeModeIndex = await settings.getValue(SettingsKeys.themeMode); + final themeModeIndex = settings.themeMode; runApp( TranslationProvider( diff --git a/lib/shared/models/settings.dart b/lib/shared/models/settings.dart index d97daaa3..497bbb3d 100644 --- a/lib/shared/models/settings.dart +++ b/lib/shared/models/settings.dart @@ -40,35 +40,43 @@ enum SettingsKeys implements Comparable> { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36', ), - /// Window width config on desktop platforms. - windowWidth( - name: 'windowWidth', - type: double, - defaultValue: 600, + /// Remember window size after window size changed on desktop platforms. + /// + /// Disable this config will never update [windowSize]. + windowRememberSize( + name: 'windowRememberSize', + type: bool, + defaultValue: true, ), - /// Window height config on desktop platforms. - windowHeight( - name: 'windowHeight', - type: double, - defaultValue: 800, + /// Window size config on desktop platforms. + windowSize( + name: 'windowSize', + type: Size, + defaultValue: Size(800, 600), ), - /// Window position config on desktop platforms. - windowPositionDx( - name: 'windowPositionX', - type: double, - defaultValue: 0, + /// Remember window position after window position changed on desktop + /// platforms. + /// + /// Disable this config will never update [windowPosition]. + windowRememberPosition( + name: 'windowRememberPosition', + type: bool, + defaultValue: true, ), /// Window position config on desktop platforms. - windowPositionDy( - name: 'windowPositionY', - type: double, - defaultValue: 0, + windowPosition( + name: 'windowPosition', + type: Offset, + defaultValue: Offset.zero, ), /// Window whether in the center of screen config on desktop platforms. + /// + /// Enable this config will disable [windowPosition] and + /// [windowRememberPosition]. windowInCenter( name: 'windowInCenter', type: bool, @@ -237,6 +245,9 @@ enum SettingsKeys implements Comparable> { final Type type; final T defaultValue; + /// Ignore dynamic generic type here because the function is used to compare + /// all types of [SettingsKeys]. @override + // ignore: avoid_dynamic int compareTo(SettingsKeys other) => name.compareTo(other.name); } diff --git a/lib/shared/models/settings_map.dart b/lib/shared/models/settings_map.dart index 62d3e68b..2d8bc9a6 100644 --- a/lib/shared/models/settings_map.dart +++ b/lib/shared/models/settings_map.dart @@ -5,16 +5,16 @@ part of 'models.dart'; /// All settings and their keys. @MappableClass() class SettingsMap with SettingsMapMappable { - /// Freezed constructor. - SettingsMap({ + /// Constructor. + const SettingsMap({ required this.netClientAccept, required this.netClientAcceptEncoding, required this.netClientAcceptLanguage, required this.netClientUserAgent, - required this.windowWidth, - required this.windowHeight, - required this.windowPositionDx, - required this.windowPositionDy, + required this.windowRememberSize, + required this.windowSize, + required this.windowRememberPosition, + required this.windowPosition, required this.windowInCenter, required this.loginUsername, required this.loginUid, @@ -42,10 +42,10 @@ class SettingsMap with SettingsMapMappable { final String netClientAcceptEncoding; final String netClientAcceptLanguage; final String netClientUserAgent; - final double windowWidth; - final double windowHeight; - final double windowPositionDx; - final double windowPositionDy; + final bool windowRememberSize; + final Size windowSize; + final bool windowRememberPosition; + final Offset windowPosition; final bool windowInCenter; final String loginUsername; final int loginUid; @@ -84,12 +84,12 @@ class SettingsMap with SettingsMapMappable { copyWith(netClientAcceptLanguage: value as String?), SettingsKeys.netClientUserAgent => copyWith(netClientUserAgent: value as String?), - SettingsKeys.windowWidth => copyWith(windowWidth: value as double?), - SettingsKeys.windowHeight => copyWith(windowHeight: value as double?), - SettingsKeys.windowPositionDx => - copyWith(windowPositionDx: value as double?), - SettingsKeys.windowPositionDy => - copyWith(windowPositionDy: value as double?), + SettingsKeys.windowRememberSize => + copyWith(windowRememberSize: value as bool?), + SettingsKeys.windowSize => copyWith(windowSize: value as Size?), + SettingsKeys.windowRememberPosition => + copyWith(windowRememberPosition: value as bool?), + SettingsKeys.windowPosition => copyWith(windowPosition: value as Offset?), SettingsKeys.windowInCenter => copyWith(windowInCenter: value as bool?), SettingsKeys.loginUsername => copyWith(loginUsername: value as String?), SettingsKeys.loginUid => copyWith(loginUid: value as int?), diff --git a/lib/shared/providers/storage_provider/models/convertable/offset.dart b/lib/shared/providers/storage_provider/models/convertable/offset.dart index 4cc37094..0e981199 100644 --- a/lib/shared/providers/storage_provider/models/convertable/offset.dart +++ b/lib/shared/providers/storage_provider/models/convertable/offset.dart @@ -10,7 +10,10 @@ class OffsetConverter extends TypeConverter { @override Offset fromSql(String fromDb) { - final jsonMap = jsonDecode(fromDb) as Map; + // ignore: avoid_dynamic + final jsonMap = Map.castFrom( + jsonDecode(fromDb) as Map, + ); return Offset(jsonMap[_keyDx]!, jsonMap[_keyDy]!); } diff --git a/lib/shared/providers/storage_provider/models/convertable/size.dart b/lib/shared/providers/storage_provider/models/convertable/size.dart index 4ce45a3d..e9450651 100644 --- a/lib/shared/providers/storage_provider/models/convertable/size.dart +++ b/lib/shared/providers/storage_provider/models/convertable/size.dart @@ -10,7 +10,10 @@ class SizeConverter extends TypeConverter { @override Size fromSql(String fromDb) { - final jsonMap = jsonDecode(fromDb) as Map; + // ignore: avoid_dynamic + final jsonMap = Map.castFrom( + jsonDecode(fromDb) as Map, + ); return Size(jsonMap[_keyWidth]!, jsonMap[_keyHeight]!); }