From 0d785bf1f8c1edf396e800604ae5af04067f95ba Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 09:54:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(library):=20=E6=94=AF=E6=8C=81=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=BF=BD=E5=89=A7=E4=B8=8E=E8=A7=82=E7=9C=8B=E5=8E=86?= =?UTF-8?q?=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 17 +-- lib/core/data/remote/watch_api.dart | 2 +- .../repositories/favorite_repository.dart | 8 +- .../watch_history_repository.dart | 12 +- .../data/services/favorite_controller.dart | 8 +- .../services/watch_records_controller.dart | 13 ++- lib/pages/favorites/favorites_page.dart | 16 +-- .../watch_records/watch_records_page.dart | 23 +++- test/library/favorites_page_test.dart | 26 ++--- test/theme/dark_theme_smoke_test.dart | 19 +++- test/watch/watch_api_test.dart | 57 ++++++++++ test/watch/watch_records_page_test.dart | 107 ++++++++++++++++-- 12 files changed, 232 insertions(+), 76 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 53133db..c47a354 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -141,17 +141,11 @@ class _LumaDramaAppState extends State { late final WatchHistoryRepository _watchHistory = widget.watchHistoryRepository ?? - HttpWatchHistoryRepository( - WatchApi(_apiClient), - isSignedIn: () => _auth.isSignedIn, - ); + HttpWatchHistoryRepository(WatchApi(_apiClient)); late final FavoriteRepository _favorites = widget.favoriteRepository ?? - HttpFavoriteRepository( - LibraryApi(_apiClient), - isSignedIn: () => _auth.isSignedIn, - ); + HttpFavoriteRepository(LibraryApi(_apiClient)); late final PlaybackRepository _playback = widget.playbackRepository ?? @@ -219,7 +213,7 @@ class _LumaDramaAppState extends State { unawaited(_localeController.load()); // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 _auth.addListener(_onAuthChanged); - if (_auth.isSignedIn) unawaited(_favoriteController.load()); + unawaited(_favoriteController.load()); if (_auth.isSignedIn) unawaited(_walletController.load()); unawaited(_downloadController.restore()); } @@ -235,6 +229,7 @@ class _LumaDramaAppState extends State { unawaited(_walletController.load()); } else { _favoriteController.clear(); + unawaited(_favoriteController.load()); _downloadController.reset(); _walletController.reset(); } @@ -293,9 +288,7 @@ class _LumaDramaAppState extends State { ), ChangeNotifierProvider(create: (_) => _createCategories()), if (playlists != null) - ChangeNotifierProvider( - create: (_) => PlaylistController(playlists), - ), + ChangeNotifierProvider(create: (_) => PlaylistController(playlists)), ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _walletController), diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index f918e8d..74d39fa 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -4,7 +4,7 @@ import '../models/page_result.dart'; import '../models/watch_progress.dart'; import 'api_client.dart'; -/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。 +/// 观看进度接口:`/app/watch/*`(契约 §1.5)。支持账号或设备身份。 class WatchApi { const WatchApi(this._client); diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart index dd7d917..3c7e861 100644 --- a/lib/core/data/repositories/favorite_repository.dart +++ b/lib/core/data/repositories/favorite_repository.dart @@ -19,21 +19,15 @@ abstract interface class FavoriteRepository { } class HttpFavoriteRepository implements FavoriteRepository { - const HttpFavoriteRepository(this._api, {this.isSignedIn}); + const HttpFavoriteRepository(this._api); final LibraryApi _api; - /// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。 - final bool Function()? isSignedIn; - - bool get _signedIn => isSignedIn?.call() ?? true; - @override Future toggle(String dramaId) => _api.toggleFavorite(dramaId); @override Future> page({int pageIndex = 1, int pageSize = 20}) async { - if (!_signedIn) return const PageResult(); return _api.favorites(pageIndex: pageIndex, pageSize: pageSize); } diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index cb6b76f..7c7937d 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -35,18 +35,12 @@ abstract interface class WatchHistoryRepository { } class HttpWatchHistoryRepository implements WatchHistoryRepository { - const HttpWatchHistoryRepository(this._api, {this.isSignedIn}); + const HttpWatchHistoryRepository(this._api); final WatchApi _api; - /// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。 - final bool Function()? isSignedIn; - - bool get _signedIn => isSignedIn?.call() ?? true; - @override Future progressFor(String dramaId) async { - if (!_signedIn) return null; return _api.progressOfDrama(dramaId); } @@ -57,7 +51,6 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository { Duration? duration, bool finished = false, }) async { - if (!_signedIn) return null; return _api.saveProgress( episodeId: episodeId, position: position, @@ -71,19 +64,16 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository { int pageIndex = 1, int pageSize = 20, }) async { - if (!_signedIn) return const PageResult(); return _api.recent(pageIndex: pageIndex, pageSize: pageSize); } @override Future remove(String dramaId) async { - if (!_signedIn) return; await _api.removeProgress(dramaId); } @override Future clear() async { - if (!_signedIn) return; await _api.clearProgress(); } } diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index b4c5ba6..29a473a 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -93,6 +93,7 @@ class FavoriteController extends ChangeNotifier { loadingMore = false; error = null; loadMoreError = null; + lastErrorCode = null; notify(); try { final page = await _repository.page(pageIndex: 1, pageSize: pageSize); @@ -106,9 +107,10 @@ class FavoriteController extends ChangeNotifier { total = page.total; _pageIndex = 1; hasMore = page.list.isNotEmpty && cards.length < total; - } catch (_) { + } catch (failure) { if (!_disposed && request == _requestSeq) { error = 'favoritesError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { @@ -124,6 +126,7 @@ class FavoriteController extends ChangeNotifier { final request = ++_requestSeq; loadingMore = true; loadMoreError = null; + lastErrorCode = null; notify(); try { final page = await _repository.page( @@ -141,9 +144,10 @@ class FavoriteController extends ChangeNotifier { total = page.total; _pageIndex += 1; hasMore = page.list.isNotEmpty && cards.length < total; - } catch (_) { + } catch (failure) { if (!_disposed && request == _requestSeq) { loadMoreError = 'favoritesError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index ec70015..4ac064a 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -38,6 +38,7 @@ class WatchRecordsController extends ChangeNotifier { String? error; String? loadMoreError; String? actionError; + int? lastErrorCode; int _pageIndex = 0; int _requestSeq = 0; bool _disposed = false; @@ -50,6 +51,7 @@ class WatchRecordsController extends ChangeNotifier { error = null; loadMoreError = null; actionError = null; + lastErrorCode = null; _notify(); try { final page = await _history.recent(pageIndex: 1, pageSize: pageSize); @@ -71,6 +73,7 @@ class WatchRecordsController extends ChangeNotifier { ); if (!_disposed && request == _requestSeq) { error = failure is ApiException ? failure.message : 'progressError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { @@ -86,6 +89,7 @@ class WatchRecordsController extends ChangeNotifier { final request = ++_requestSeq; loadingMore = true; loadMoreError = null; + lastErrorCode = null; _notify(); try { final page = await _history.recent( @@ -108,6 +112,7 @@ class WatchRecordsController extends ChangeNotifier { loadMoreError = failure is ApiException ? failure.message : 'progressError'; + if (failure is ApiException) lastErrorCode = failure.code; } } finally { if (!_disposed && request == _requestSeq) { @@ -148,17 +153,19 @@ class WatchRecordsController extends ChangeNotifier { total = total > 0 ? total - 1 : 0; hasMore = records.length < total; actionError = null; + lastErrorCode = null; _notify(); try { await _history.remove(dramaId); return true; - } catch (_) { + } catch (failure) { final next = [...records]; next.insert(index > next.length ? next.length : index, removed); records = next; total += 1; hasMore = records.length < total; actionError = 'recordDeleteFailed'; + if (failure is ApiException) lastErrorCode = failure.code; return false; } finally { _notify(); @@ -179,16 +186,18 @@ class WatchRecordsController extends ChangeNotifier { loadingMore = false; clearing = true; actionError = null; + lastErrorCode = null; _notify(); try { await _history.clear(); return true; - } catch (_) { + } catch (failure) { records = previous; total = previousTotal; _pageIndex = previousPageIndex; hasMore = records.length < total; actionError = 'recordClearFailed'; + if (failure is ApiException) lastErrorCode = failure.code; return false; } finally { clearing = false; diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 5e0d871..c4559e1 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/formatting/error_text.dart'; @@ -77,11 +76,6 @@ class _FavoritesContentState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - final signedIn = context.watch().isSignedIn; - if (!signedIn) { - _requested = false; - return; - } if (_requested) return; _requested = true; final favorites = context.read(); @@ -93,7 +87,6 @@ class _FavoritesContentState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final signedIn = context.watch().isSignedIn; return SafeArea( bottom: false, child: Column( @@ -104,9 +97,9 @@ class _FavoritesContentState extends State { showBack: widget.showBack, ), Expanded( - child: signedIn - ? _FavoritesBody(favorites: context.watch()) - : const AuthGatePanel(), + child: _FavoritesBody( + favorites: context.watch(), + ), ), ], ), @@ -122,6 +115,9 @@ class _FavoritesBody extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + if (favorites.lastErrorCode == 200007) { + return const AuthGatePanel(); + } if (favorites.loading && favorites.cards.isEmpty) { return const Center( child: CircularProgressIndicator(key: Key('favorites-loading')), diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index b244c63..62db3ad 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -35,7 +35,6 @@ class WatchRecordsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final signedIn = context.watch().isSignedIn; final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.dark(), @@ -49,11 +48,7 @@ class WatchRecordsPage extends StatelessWidget { title: l10n.watchHistory, backKey: const Key('watch-records-back'), ), - Expanded( - child: signedIn - ? const _WatchRecordsBody() - : const AuthGatePanel(), - ), + Expanded(child: const _WatchRecordsBody()), ], ), ), @@ -71,10 +66,15 @@ class _WatchRecordsBody extends StatefulWidget { class _WatchRecordsBodyState extends State<_WatchRecordsBody> { late final WatchRecordsController controller; + late final AuthController _auth; + late bool _signedIn; @override void initState() { super.initState(); + _auth = context.read(); + _signedIn = _auth.isSignedIn; + _auth.addListener(_handleAuthChanged); controller = WatchRecordsBinding().create( history: context.read(), catalog: context.read(), @@ -82,8 +82,16 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { unawaited(controller.load()); } + void _handleAuthChanged() { + final signedIn = _auth.isSignedIn; + if (_signedIn == signedIn || !mounted) return; + _signedIn = signedIn; + unawaited(controller.load()); + } + @override void dispose() { + _auth.removeListener(_handleAuthChanged); controller.dispose(); super.dispose(); } @@ -98,6 +106,9 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; + if (controller.lastErrorCode == 200007) { + return const AuthGatePanel(); + } if (controller.loading && controller.records.isEmpty) { return const Center( child: CircularProgressIndicator(key: Key('watch-records-loading')), diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 4599f42..d378a89 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -187,22 +187,20 @@ void main() { expect(find.byKey(const Key('favorites-retry')), findsOneWidget); }); - testWidgets( - 'should_show_auth_gate_without_loading_favorites_when_signed_out', - (tester) async { - final auth = await _auth(signedIn: false); - final repository = InMemoryFavoriteRepository( - saved: {'signal'}, - cards: [_card('signal')], - ); - final favorites = FavoriteController(repository); + testWidgets('should_show_device_favorites_when_signed_out', (tester) async { + final auth = await _auth(signedIn: false); + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + ); + final favorites = FavoriteController(repository); - await _pumpPage(tester, auth: auth, favorites: favorites); + await _pumpPage(tester, auth: auth, favorites: favorites); - expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); - expect(repository.pageCalls, 0); - }, - ); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.text('Drama signal'), findsOneWidget); + expect(repository.pageCalls, 1); + }); testWidgets('should_open_player_when_favorite_card_is_tapped', ( tester, diff --git a/test/theme/dark_theme_smoke_test.dart b/test/theme/dark_theme_smoke_test.dart index 7ece6f6..a5963ab 100644 --- a/test/theme/dark_theme_smoke_test.dart +++ b/test/theme/dark_theme_smoke_test.dart @@ -4,8 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; @@ -15,6 +18,8 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import '../support/fake_http_adapter.dart'; +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; void main() { setUp(() { @@ -92,8 +97,15 @@ void main() { ); await tester.pumpWidget( - ChangeNotifierProvider.value( - value: auth, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakeCatalogRepository()), + Provider.value(value: FakeHaptics()), + ], child: MaterialApp( theme: AppTheme.dark(), darkTheme: AppTheme.dark(), @@ -111,7 +123,8 @@ void main() { find.byKey(const Key('watch-records-page')), ); expect(scaffold.backgroundColor, AppColors.ink); - expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.byKey(const Key('watch-records-empty')), findsOneWidget); expect(find.byType(Scaffold), findsOneWidget); }); } diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart index 3214b41..22611d5 100644 --- a/test/watch/watch_api_test.dart +++ b/test/watch/watch_api_test.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/watch_api.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import '../support/fake_http_adapter.dart'; @@ -94,4 +95,60 @@ void main() { ), ); }); + + test('should_allow_guest_watch_read_save_and_clear', () async { + final adapter = FakeHttpAdapter((options) async { + if (options.method == 'GET' && options.uri.path.endsWith('/progress')) { + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'dramaId': '12', + 'episodeId': '88', + 'positionMs': '8000', + 'durationMs': '60000', + }, + }), + ); + } + if (options.method == 'GET') { + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'total': '0', 'list': []}, + }), + ); + } + return FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ); + }); + final repository = HttpWatchHistoryRepository( + WatchApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + ); + + expect((await repository.progressFor('12'))?.episodeId, '88'); + expect((await repository.recent()).list, isEmpty); + await repository.save( + episodeId: '88', + position: const Duration(seconds: 8), + ); + await repository.remove('12'); + await repository.clear(); + + expect( + adapter.requests.map( + (request) => '${request.method} ${request.uri.path}', + ), + [ + 'GET /app/watch/progress', + 'GET /app/watch/recent', + 'POST /app/watch/progress', + 'DELETE /app/watch/progress/12', + 'DELETE /app/watch/progress', + ], + ); + }); } diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 9b8fc26..db2f3c4 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -174,9 +174,7 @@ void main() { }, ); - testWidgets('should_show_auth_gate_without_loading_records_when_signed_out', ( - tester, - ) async { + testWidgets('should_show_device_history_when_signed_out', (tester) async { final auth = await _auth(signedIn: false); final history = CountingWatchHistory([_progress('the-signal-demo')]); @@ -187,8 +185,59 @@ void main() { catalog: FakeCatalogRepository(), ); - expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); - expect(history.recentCalls, 0); + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.text('The Signal'), findsOneWidget); + expect(history.recentCalls, 1); + }); + + testWidgets('should_reload_device_history_after_login', (tester) async { + final auth = _ToggleAuthController(); + final history = CountingWatchHistory([_progress('the-signal-demo')]); + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + expect(history.recentCalls, 1); + + auth.setSignedIn(true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(history.recentCalls, 2); + expect(find.text('The Signal'), findsOneWidget); + }); + + testWidgets('should_remove_and_clear_device_history_when_signed_out', ( + tester, + ) async { + final auth = await _auth(signedIn: false); + final history = CountingWatchHistory([ + _progress('the-signal-demo'), + _progress('city-lights-demo'), + ]); + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: FakeCatalogRepository(), + ); + + await tester.tap( + find.byKey(const Key('watch-record-remove-the-signal-demo')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('watch-record-the-signal-demo')), findsNothing); + expect(history.removeCalls, 1); + + await tester.tap(find.byKey(const Key('watch-records-clear'))); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear')); + await tester.pumpAndSettle(); + + expect(history.clearCalls, 1); + expect(find.byKey(const Key('watch-records-empty')), findsOneWidget); }); testWidgets( @@ -313,9 +362,17 @@ Future _auth({required bool signedIn}) async { } final client = ApiClient( baseUrl: 'http://127.0.0.1:8081', - adapter: FakeHttpAdapter( - (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), - ), + adapter: FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/login') { + return FakeHttpResponse( + '{"code":0,"message":"ok","data":{' + '"accessToken":"test-access","refreshToken":"test-refresh",' + '"expiresIn":7200,"user":{"uid":"1","nickname":"Viewer",' + '"avatarUrl":""}}}', + ); + } + return FakeHttpResponse('{"code":0,"message":"ok","data":null}'); + }), ); final auth = AuthController(AuthApi(client), AuthSessionStore()); await auth.load(); @@ -353,6 +410,7 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { final List visibleRecords; int recentCalls = 0; + int removeCalls = 0; int clearCalls = 0; @override @@ -371,6 +429,12 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { ); } + @override + Future remove(String dramaId) async { + removeCalls++; + await super.remove(dramaId); + } + @override Future clear() async { clearCalls++; @@ -440,3 +504,30 @@ class _AlwaysFailingWatchCatalog implements CatalogRepository { @override Future fetchHomeFeed() async => const HomeFeed(); } + +class _ToggleAuthController extends AuthController { + _ToggleAuthController() + : super( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: FakeHttpAdapter( + (_) async => + FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ), + ), + AuthSessionStore(), + ); + + bool _signedIn = false; + + @override + bool get isSignedIn => _signedIn; + + void setSignedIn(bool value) { + if (_signedIn == value) return; + _signedIn = value; + notifyListeners(); + } +}