diff --git a/lib/main.dart b/lib/main.dart --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,8 @@ import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/features/connectivity/presentation/connectivity_banner_host.dart'; import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; @@ -57,6 +59,7 @@ final settingsCubit = SettingsCubit(database: database); await settingsCubit.loadSettings(); + final connectivityCubit = ConnectivityCubit(simulateOffline: settingsCubit.state.simulateOffline); final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); await accountSwitcherCubit.loadAccounts(); @@ -68,6 +71,7 @@ authBloc: authBloc, database: database, settingsCubit: settingsCubit, + connectivityCubit: connectivityCubit, accountSwitcherCubit: accountSwitcherCubit, ), ); @@ -79,12 +83,14 @@ required this.authBloc, required this.database, required this.settingsCubit, + required this.connectivityCubit, required this.accountSwitcherCubit, }); final AuthBloc authBloc; final AppDatabase database; final SettingsCubit settingsCubit; + final ConnectivityCubit connectivityCubit; final AccountSwitcherCubit accountSwitcherCubit; @override @@ -96,6 +102,7 @@ late GoRouter _router; late String _routerSessionKey; late final StreamSubscription _authSubscription; + late final StreamSubscription _simulateOfflineSubscription; @override void initState() { @@ -103,11 +110,17 @@ _routerSessionKey = _sessionKeyFor(widget.authBloc.state); _router = _createRouter(); _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); + _simulateOfflineSubscription = widget.settingsCubit.stream + .map((state) => state.simulateOffline) + .distinct() + .listen(widget.connectivityCubit.setSimulatedOffline); } @override void dispose() { _authSubscription.cancel(); + _simulateOfflineSubscription.cancel(); + widget.connectivityCubit.close(); _router.dispose(); super.dispose(); } @@ -147,6 +160,7 @@ providers: [ BlocProvider.value(value: widget.authBloc), BlocProvider.value(value: widget.settingsCubit), + BlocProvider.value(value: widget.connectivityCubit), BlocProvider.value(value: widget.accountSwitcherCubit), ], child: BlocBuilder( @@ -170,6 +184,7 @@ darkTheme: darkTheme, themeMode: themeMode, routerConfig: _router, + builder: (context, child) => ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), ); }, ); @@ -198,8 +213,12 @@ dispose: (moderationService) => moderationService.dispose(), ), RepositoryProvider( - create: (context) => - FeedRepository(bluesky: bluesky, moderationService: context.read()), + create: (context) => FeedRepository( + bluesky: bluesky, + database: widget.database, + accountDid: accountDid, + moderationService: context.read(), + ), ), RepositoryProvider( create: (context) => diff --git a/docs/specs/phase-5.md b/docs/specs/phase-5.md --- a/docs/specs/phase-5.md +++ b/docs/specs/phase-5.md @@ -1,13 +1,106 @@ --- title: Phase 5 Spec -updated: 2026-03-23 +updated: 2026-03-25 --- ## Feature Parity -- Endpoints to build UI around: - - In search screen: `/xrpc/app.bsky.graph.searchStarterPacks` - - In profile screen: `/xrpc/app.bsky.graph.getSuggestedFollowsByActor`, should be a - sheet accessible via overflow menu - - In settings screen: `/xrpc/app.bsky.video.getUploadLimits` to show remaining daily - video upload limits +Three new endpoint integrations to round out UI coverage. + +--- + +### 1. Starter Pack Search (Search Screen) + +**Endpoint:** `GET /xrpc/app.bsky.graph.searchStarterPacks` +**Auth:** Not required + +**Request:** + +| Param | Type | Required | Default | Notes | +|----------|--------|----------|---------|-------------------------------| +| `q` | string | yes | — | Lucene-style query | +| `limit` | int | no | 25 | 1–100 | +| `cursor` | string | no | — | Pagination cursor | + +**Response:** + +```json +{ + "cursor": "string?", + "starterPacks": "StarterPackViewBasic[]" +} +``` + +`StarterPackViewBasic` includes: `uri`, `cid`, `record`, `creator` (ProfileViewBasic), +`listItemCount?`, `joinedWeekCount?`, `joinedAllTimeCount?`, `labels?`, `indexedAt`. + +**SDK:** `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)` +→ `XRPCResponse` + +**UI:** Add a third "Starter Packs" tab to the search screen alongside Posts and People. +Tapping a result navigates to the existing starter pack detail screen. Infinite scroll +pagination via cursor. Reuse the existing `StarterPackViewBasic` tile pattern from +the profile starter packs tab. + +--- + +### 2. Suggested Follows (Profile Screen) + +**Endpoint:** `GET /xrpc/app.bsky.graph.getSuggestedFollowsByActor` +**Auth:** Not required + +**Request:** + +| Param | Type | Required | Notes | +|---------|--------|----------|-------------------| +| `actor` | string | yes | DID or handle | + +**Response:** + +```json +{ + "suggestions": "ProfileView[]", + "isFallback": "bool (default false)", + "recIdStr": "string?" +} +``` + +No pagination — returns all suggestions in one response. + +**SDK:** `bluesky.graph.getSuggestedFollowsByActor(actor:)` +→ `XRPCResponse` + +**UI:** New "Suggested Follows" entry in the profile screen's overflow (more options) +bottom sheet. Opens a draggable scrollable sheet listing `ProfileView` tiles with +follow/unfollow buttons. Each tile navigates to the user's profile on tap. Show empty +state if `suggestions` is empty. Hide the menu entry when viewing own profile. + +--- + +### 3. Video Upload Limits (Settings Screen) + +**Endpoint:** `GET /xrpc/app.bsky.video.getUploadLimits` +**Auth:** Required + +**Request:** None + +**Response:** + +```json +{ + "canUpload": "bool", + "remainingDailyVideos": "int?", + "remainingDailyBytes": "int?", + "message": "string?", + "error": "string?" +} +``` + +**SDK:** `bluesky.video.getUploadLimits()` +→ `XRPCResponse` + +**UI:** New tile in the settings screen's Account section showing daily video upload +quota. Display remaining video count and remaining bytes (formatted as MB/GB). +Show `canUpload` status and any server `message`. Fetch on screen load; show +loading indicator while fetching. If the endpoint returns an error or `canUpload` +is false, show the reason. diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -22,11 +22,12 @@ ## M15 — Offline Reading & Network Resilience - [x] `ConnectivityCubit` via **connectivity_plus** — expose network state stream -- [ ] Cache last-fetched feed page as serialised JSON in Drift -- [ ] Display cached data immediately on launch, refresh in background -- [ ] "You're offline" banner when connectivity is lost -- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip -- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache +- [x] Cache last-fetched feed page as serialised JSON in Drift +- [x] Display cached data immediately on launch, refresh in background +- [x] "You're offline" banner when connectivity is lost +- [x] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip +- [x] Notifications and DM screens show "No connection" empty state when offline with no cache +- [x] In Debug/Dev mode, add "Simulate Offline" toggle in settings to test offline UI ## M16 — Jump to Profile diff --git a/docs/tasks/phase-5.md b/docs/tasks/phase-5.md --- a/docs/tasks/phase-5.md +++ b/docs/tasks/phase-5.md @@ -1,4 +1,75 @@ --- title: Phase 5 Task Breakdown -updated: 2026-03-23 +updated: 2026-03-25 --- + +# Phase 5 Milestones + +## M20 — Starter Pack Search + +### Core + +- [ ] `SearchRepository.searchStarterPacks()` — call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List` and cursor +- [ ] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension + +### Cubit + +- [ ] `SearchBloc` — handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination +- [ ] `SearchState` — add `starterPacks` list and `starterPacksCursor` fields + +### UI + +- [ ] Search screen UI — add third "Starter Packs" tab pill in `_buildTab` row +- [ ] Starter pack result tile widget — show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab +- [ ] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`) +- [ ] Infinite scroll pagination for starter packs tab + +### Tests + +- [ ] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination +- [ ] Widget tests: third tab renders, results display, empty state, tap navigation + +## M21 — Suggested Follows Sheet + +### Core + +- [ ] `ProfileRepository.getSuggestedFollows()` — call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List` + +### Cubit + +- [ ] `SuggestedFollowsCubit` — `load(actor:)` fetches suggestions, exposes loaded/loading/error states + +### UI + +- [ ] Suggested follows sheet widget — `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons +- [ ] Profile screen overflow menu — add "Suggested Follows" `ListTile` entry; hide when viewing own profile +- [ ] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete` +- [ ] Tap profile tile → pop sheet, navigate to profile screen +- [ ] Empty state when no suggestions returned + +### Tests + +- [ ] Unit tests: repository method, cubit state transitions +- [ ] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state + +## M22 — Video Upload Limits + +### Core + +- [ ] `VideoRepository` (or extend settings repository) — `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result + +### Cubit + +- [ ] `VideoUploadLimitsCubit` — fetch on init, expose `canUpload`, remaining counts, message/error + +### UI + +- [ ] Settings screen — new tile in Account section: "Video Upload Limits" +- [ ] Tile UI — show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge +- [ ] Loading state while fetching, error state if request fails +- [ ] Display server `message` if present; show `error` text with warning styling if `canUpload` is false + +### Tests + +- [ ] Unit tests: repository method, cubit state transitions and formatting +- [ ] Widget tests: tile renders limits, loading indicator, error state, message display diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -12,6 +12,7 @@ CachedPosts, Settings, SavedFeeds, + CachedFeedPages, SearchHistory, Drafts, SavedPosts, @@ -24,7 +25,7 @@ static const activeAccountDidSettingKey = 'active_account_did'; @override - int get schemaVersion => 11; + int get schemaVersion => 12; @override MigrationStrategy get migration => MigrationStrategy( @@ -70,6 +71,9 @@ The thread auto-collapse setting is nullable and represented by the presence or absence of a row in the existing settings table. */ + } + if (from < 12) { + await migrator.createTable(cachedFeedPages); } }, ); @@ -181,6 +185,33 @@ Future deleteAllSavedFeeds(String accountDid) => (delete(savedFeeds)..where((f) => f.accountDid.equals(accountDid))).go(); + + Future cacheFeedPage({ + required String accountDid, + required String feedKey, + required String payload, + DateTime? fetchedAt, + }) => into(cachedFeedPages).insert( + CachedFeedPagesCompanion( + accountDid: Value(accountDid), + feedKey: Value(feedKey), + payload: Value(payload), + fetchedAt: Value(fetchedAt ?? DateTime.now()), + ), + mode: InsertMode.replace, + ); + + Future getCachedFeedPage(String accountDid, String feedKey) { + return (select( + cachedFeedPages, + )..where((entry) => entry.accountDid.equals(accountDid) & entry.feedKey.equals(feedKey))).getSingleOrNull(); + } + + Future deleteCachedFeedPage(String accountDid, String feedKey) { + return (delete( + cachedFeedPages, + )..where((entry) => entry.accountDid.equals(accountDid) & entry.feedKey.equals(feedKey))).go(); + } Future replaceSavedFeeds(String accountDid, List feeds) async { await transaction(() async { diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -1825,6 +1825,276 @@ } } +class $CachedFeedPagesTable extends CachedFeedPages with TableInfo<$CachedFeedPagesTable, CachedFeedPage> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedFeedPagesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _accountDidMeta = const VerificationMeta('accountDid'); + @override + late final GeneratedColumn accountDid = GeneratedColumn( + 'account_did', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _feedKeyMeta = const VerificationMeta('feedKey'); + @override + late final GeneratedColumn feedKey = GeneratedColumn( + 'feed_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _payloadMeta = const VerificationMeta('payload'); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _fetchedAtMeta = const VerificationMeta('fetchedAt'); + @override + late final GeneratedColumn fetchedAt = GeneratedColumn( + 'fetched_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [accountDid, feedKey, payload, fetchedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_feed_pages'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('account_did')) { + context.handle(_accountDidMeta, accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta)); + } else if (isInserting) { + context.missing(_accountDidMeta); + } + if (data.containsKey('feed_key')) { + context.handle(_feedKeyMeta, feedKey.isAcceptableOrUnknown(data['feed_key']!, _feedKeyMeta)); + } else if (isInserting) { + context.missing(_feedKeyMeta); + } + if (data.containsKey('payload')) { + context.handle(_payloadMeta, payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('fetched_at')) { + context.handle(_fetchedAtMeta, fetchedAt.isAcceptableOrUnknown(data['fetched_at']!, _fetchedAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {accountDid, feedKey}; + @override + CachedFeedPage map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedFeedPage( + accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_did'])!, + feedKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}feed_key'])!, + payload: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}payload'])!, + fetchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}fetched_at'])!, + ); + } + + @override + $CachedFeedPagesTable createAlias(String alias) { + return $CachedFeedPagesTable(attachedDatabase, alias); + } +} + +class CachedFeedPage extends DataClass implements Insertable { + final String accountDid; + final String feedKey; + final String payload; + final DateTime fetchedAt; + const CachedFeedPage({ + required this.accountDid, + required this.feedKey, + required this.payload, + required this.fetchedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['account_did'] = Variable(accountDid); + map['feed_key'] = Variable(feedKey); + map['payload'] = Variable(payload); + map['fetched_at'] = Variable(fetchedAt); + return map; + } + + CachedFeedPagesCompanion toCompanion(bool nullToAbsent) { + return CachedFeedPagesCompanion( + accountDid: Value(accountDid), + feedKey: Value(feedKey), + payload: Value(payload), + fetchedAt: Value(fetchedAt), + ); + } + + factory CachedFeedPage.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedFeedPage( + accountDid: serializer.fromJson(json['accountDid']), + feedKey: serializer.fromJson(json['feedKey']), + payload: serializer.fromJson(json['payload']), + fetchedAt: serializer.fromJson(json['fetchedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'accountDid': serializer.toJson(accountDid), + 'feedKey': serializer.toJson(feedKey), + 'payload': serializer.toJson(payload), + 'fetchedAt': serializer.toJson(fetchedAt), + }; + } + + CachedFeedPage copyWith({String? accountDid, String? feedKey, String? payload, DateTime? fetchedAt}) => + CachedFeedPage( + accountDid: accountDid ?? this.accountDid, + feedKey: feedKey ?? this.feedKey, + payload: payload ?? this.payload, + fetchedAt: fetchedAt ?? this.fetchedAt, + ); + CachedFeedPage copyWithCompanion(CachedFeedPagesCompanion data) { + return CachedFeedPage( + accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, + feedKey: data.feedKey.present ? data.feedKey.value : this.feedKey, + payload: data.payload.present ? data.payload.value : this.payload, + fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedFeedPage(') + ..write('accountDid: $accountDid, ') + ..write('feedKey: $feedKey, ') + ..write('payload: $payload, ') + ..write('fetchedAt: $fetchedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(accountDid, feedKey, payload, fetchedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedFeedPage && + other.accountDid == this.accountDid && + other.feedKey == this.feedKey && + other.payload == this.payload && + other.fetchedAt == this.fetchedAt); +} + +class CachedFeedPagesCompanion extends UpdateCompanion { + final Value accountDid; + final Value feedKey; + final Value payload; + final Value fetchedAt; + final Value rowid; + const CachedFeedPagesCompanion({ + this.accountDid = const Value.absent(), + this.feedKey = const Value.absent(), + this.payload = const Value.absent(), + this.fetchedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + CachedFeedPagesCompanion.insert({ + required String accountDid, + required String feedKey, + required String payload, + this.fetchedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : accountDid = Value(accountDid), + feedKey = Value(feedKey), + payload = Value(payload); + static Insertable custom({ + Expression? accountDid, + Expression? feedKey, + Expression? payload, + Expression? fetchedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (accountDid != null) 'account_did': accountDid, + if (feedKey != null) 'feed_key': feedKey, + if (payload != null) 'payload': payload, + if (fetchedAt != null) 'fetched_at': fetchedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + CachedFeedPagesCompanion copyWith({ + Value? accountDid, + Value? feedKey, + Value? payload, + Value? fetchedAt, + Value? rowid, + }) { + return CachedFeedPagesCompanion( + accountDid: accountDid ?? this.accountDid, + feedKey: feedKey ?? this.feedKey, + payload: payload ?? this.payload, + fetchedAt: fetchedAt ?? this.fetchedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (accountDid.present) { + map['account_did'] = Variable(accountDid.value); + } + if (feedKey.present) { + map['feed_key'] = Variable(feedKey.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (fetchedAt.present) { + map['fetched_at'] = Variable(fetchedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedFeedPagesCompanion(') + ..write('accountDid: $accountDid, ') + ..write('feedKey: $feedKey, ') + ..write('payload: $payload, ') + ..write('fetchedAt: $fetchedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $SearchHistoryTable extends SearchHistory with TableInfo<$SearchHistoryTable, SearchHistoryEntry> { @override final GeneratedDatabase attachedDatabase; @@ -3275,6 +3545,7 @@ late final $CachedPostsTable cachedPosts = $CachedPostsTable(this); late final $SettingsTable settings = $SettingsTable(this); late final $SavedFeedsTable savedFeeds = $SavedFeedsTable(this); + late final $CachedFeedPagesTable cachedFeedPages = $CachedFeedPagesTable(this); late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); late final $DraftsTable drafts = $DraftsTable(this); late final $SavedPostsTable savedPosts = $SavedPostsTable(this); @@ -3288,6 +3559,7 @@ cachedPosts, settings, savedFeeds, + cachedFeedPages, searchHistory, drafts, savedPosts, @@ -4181,6 +4453,153 @@ SavedFeedEntry, PrefetchHooks Function() >; +typedef $$CachedFeedPagesTableCreateCompanionBuilder = + CachedFeedPagesCompanion Function({ + required String accountDid, + required String feedKey, + required String payload, + Value fetchedAt, + Value rowid, + }); +typedef $$CachedFeedPagesTableUpdateCompanionBuilder = + CachedFeedPagesCompanion Function({ + Value accountDid, + Value feedKey, + Value payload, + Value fetchedAt, + Value rowid, + }); + +class $$CachedFeedPagesTableFilterComposer extends Composer<_$AppDatabase, $CachedFeedPagesTable> { + $$CachedFeedPagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get accountDid => + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnFilters(column)); + + ColumnFilters get feedKey => + $composableBuilder(column: $table.feedKey, builder: (column) => ColumnFilters(column)); + + ColumnFilters get payload => + $composableBuilder(column: $table.payload, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnFilters(column)); +} + +class $$CachedFeedPagesTableOrderingComposer extends Composer<_$AppDatabase, $CachedFeedPagesTable> { + $$CachedFeedPagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get accountDid => + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get feedKey => + $composableBuilder(column: $table.feedKey, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get payload => + $composableBuilder(column: $table.payload, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fetchedAt => + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$CachedFeedPagesTableAnnotationComposer extends Composer<_$AppDatabase, $CachedFeedPagesTable> { + $$CachedFeedPagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get accountDid => $composableBuilder(column: $table.accountDid, builder: (column) => column); + + GeneratedColumn get feedKey => $composableBuilder(column: $table.feedKey, builder: (column) => column); + + GeneratedColumn get payload => $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get fetchedAt => $composableBuilder(column: $table.fetchedAt, builder: (column) => column); +} + +class $$CachedFeedPagesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CachedFeedPagesTable, + CachedFeedPage, + $$CachedFeedPagesTableFilterComposer, + $$CachedFeedPagesTableOrderingComposer, + $$CachedFeedPagesTableAnnotationComposer, + $$CachedFeedPagesTableCreateCompanionBuilder, + $$CachedFeedPagesTableUpdateCompanionBuilder, + (CachedFeedPage, BaseReferences<_$AppDatabase, $CachedFeedPagesTable, CachedFeedPage>), + CachedFeedPage, + PrefetchHooks Function() + > { + $$CachedFeedPagesTableTableManager(_$AppDatabase db, $CachedFeedPagesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$CachedFeedPagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$CachedFeedPagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$CachedFeedPagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value accountDid = const Value.absent(), + Value feedKey = const Value.absent(), + Value payload = const Value.absent(), + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedFeedPagesCompanion( + accountDid: accountDid, + feedKey: feedKey, + payload: payload, + fetchedAt: fetchedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String accountDid, + required String feedKey, + required String payload, + Value fetchedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => CachedFeedPagesCompanion.insert( + accountDid: accountDid, + feedKey: feedKey, + payload: payload, + fetchedAt: fetchedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedFeedPagesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CachedFeedPagesTable, + CachedFeedPage, + $$CachedFeedPagesTableFilterComposer, + $$CachedFeedPagesTableOrderingComposer, + $$CachedFeedPagesTableAnnotationComposer, + $$CachedFeedPagesTableCreateCompanionBuilder, + $$CachedFeedPagesTableUpdateCompanionBuilder, + (CachedFeedPage, BaseReferences<_$AppDatabase, $CachedFeedPagesTable, CachedFeedPage>), + CachedFeedPage, + PrefetchHooks Function() + >; typedef $$SearchHistoryTableCreateCompanionBuilder = SearchHistoryCompanion Function({ Value id, @@ -4896,6 +5315,8 @@ $$CachedPostsTableTableManager get cachedPosts => $$CachedPostsTableTableManager(_db, _db.cachedPosts); $$SettingsTableTableManager get settings => $$SettingsTableTableManager(_db, _db.settings); $$SavedFeedsTableTableManager get savedFeeds => $$SavedFeedsTableTableManager(_db, _db.savedFeeds); + $$CachedFeedPagesTableTableManager get cachedFeedPages => + $$CachedFeedPagesTableTableManager(_db, _db.cachedFeedPages); $$SearchHistoryTableTableManager get searchHistory => $$SearchHistoryTableTableManager(_db, _db.searchHistory); $$DraftsTableTableManager get drafts => $$DraftsTableTableManager(_db, _db.drafts); $$SavedPostsTableTableManager get savedPosts => $$SavedPostsTableTableManager(_db, _db.savedPosts); diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -67,6 +67,17 @@ Set get primaryKey => {id, accountDid}; } +@DataClassName('CachedFeedPage') +class CachedFeedPages extends Table { + TextColumn get accountDid => text()(); + TextColumn get feedKey => text()(); + TextColumn get payload => text()(); + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {accountDid, feedKey}; +} + @DataClassName('SearchHistoryEntry') class SearchHistory extends Table { IntColumn get id => integer().autoIncrement()(); diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -2,6 +2,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:provider/provider.dart'; @@ -141,6 +143,7 @@ final currentPath = GoRouterState.of(rootContext).uri.path; final isMessagesRoute = currentPath.startsWith('/alerts/messages') || currentPath.startsWith('/alerts/requests'); final isNotificationsRoute = currentPath.startsWith('/alerts') && !isMessagesRoute; + final isOffline = rootContext.read().state.isOffline; final tokens = rootContext.watch().state.tokens; final displayName = tokens?.displayName ?? tokens?.handle ?? 'Guest'; final handle = tokens?.handle ?? 'Sign in required'; @@ -260,7 +263,8 @@ icon: Icons.add_circle_outline, selectedIcon: Icons.add_circle, label: 'New Post', - onTap: () => _pushRoute(context, '/compose'), + tooltip: isOffline ? offlineActionMessage('compose a post') : null, + onTap: isOffline ? null : () => _pushRoute(context, '/compose'), ), _MenuTile( icon: Icons.settings_outlined, @@ -408,15 +412,17 @@ this.isSelected = false, this.isDestructive = false, this.trailing, + this.tooltip, }); final IconData icon; final IconData selectedIcon; final String label; - final VoidCallback onTap; + final VoidCallback? onTap; final bool isSelected; final bool isDestructive; final Widget? trailing; + final String? tooltip; @override Widget build(BuildContext context) { @@ -427,7 +433,7 @@ ? theme.colorScheme.primary : theme.colorScheme.onSurface; - return ListTile( + Widget tile = ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), leading: Icon(isSelected ? selectedIcon : icon, color: color), title: Text( @@ -439,5 +445,11 @@ selectedTileColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.45), onTap: onTap, ); + + if (tooltip != null) { + tile = Tooltip(message: tooltip!, child: tile); + } + + return tile; } } diff --git a/lib/features/connectivity/connectivity_helpers.dart b/lib/features/connectivity/connectivity_helpers.dart new file mode 100644 --- /dev/null +++ b/lib/features/connectivity/connectivity_helpers.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; + +bool isOffline(BuildContext context) => context.select((cubit) => cubit.state.isOffline); + +String offlineActionMessage(String action) => 'You\'re offline. Reconnect to $action.'; + +void showOfflineSnackBar(BuildContext context, {required String action}) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(offlineActionMessage(action)), behavior: SnackBarBehavior.floating)); +} diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -190,6 +190,18 @@ final cached = await database.select(database.cachedPosts).getSingle(); expect(cached.authorDid, equals('did:plc:abc123')); }); + + test('should cache a feed page payload', () async { + await database.cacheFeedPage( + accountDid: 'did:plc:test', + feedKey: 'timeline', + payload: '{"cursor":"next","posts":[]}', + ); + + final cached = await database.getCachedFeedPage('did:plc:test', 'timeline'); + expect(cached, isNotNull); + expect(cached!.payload, equals('{"cursor":"next","posts":[]}')); + }); }); group('Settings operations', () { diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -9,6 +9,7 @@ import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; @@ -30,6 +31,8 @@ class MockSettingsCubit extends MockCubit implements SettingsCubit {} +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + class MockAccountSwitcherCubit extends MockCubit implements AccountSwitcherCubit {} class MockUnreadCountCubit extends MockCubit implements UnreadCountCubit {} @@ -44,6 +47,7 @@ late MockProfileBloc profileBloc; late MockFeedBloc feedBloc; late MockSettingsCubit settingsCubit; + late MockConnectivityCubit connectivityCubit; late MockAccountSwitcherCubit accountSwitcherCubit; late MockUnreadCountCubit unreadCountCubit; late MockConvoListBloc convoListBloc; @@ -75,6 +79,7 @@ profileBloc = MockProfileBloc(); feedBloc = MockFeedBloc(); settingsCubit = MockSettingsCubit(); + connectivityCubit = MockConnectivityCubit(); accountSwitcherCubit = MockAccountSwitcherCubit(); unreadCountCubit = MockUnreadCountCubit(); convoListBloc = MockConvoListBloc(); @@ -95,6 +100,7 @@ useSystemTheme: false, ), ); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); @@ -127,6 +133,11 @@ ), ); whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); + whenListen( accountSwitcherCubit, const Stream.empty(), initialState: const AccountSwitcherState.ready(accounts: []), @@ -150,6 +161,7 @@ BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), BlocProvider.value(value: accountSwitcherCubit), BlocProvider.value(value: unreadCountCubit), BlocProvider.value(value: convoListBloc), @@ -276,6 +288,7 @@ BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), BlocProvider.value(value: accountSwitcherCubit), ], child: BlocBuilder( diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; class ComposeScreen extends StatefulWidget { @@ -513,14 +515,19 @@ TextButton(onPressed: _saveDraft, child: const Text('Save Draft')), BlocBuilder( builder: (context, state) { + final isOffline = context.select((cubit) => cubit.state.isOffline); + final button = TextButton( + onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, + child: state.isSubmitting + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Post'), + ); + return Padding( padding: const EdgeInsets.only(right: 8), - child: TextButton( - onPressed: state.canSubmit && !state.isSubmitting ? _submitPost : null, - child: state.isSubmitting - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Post'), - ), + child: isOffline + ? Tooltip(message: offlineActionMessage('publish your post'), child: button) + : button, ); }, ), diff --git a/lib/features/connectivity/cubit/connectivity_cubit.dart b/lib/features/connectivity/cubit/connectivity_cubit.dart --- a/lib/features/connectivity/cubit/connectivity_cubit.dart +++ b/lib/features/connectivity/cubit/connectivity_cubit.dart @@ -7,27 +7,39 @@ part 'connectivity_state.dart'; class ConnectivityCubit extends Cubit { - ConnectivityCubit({Connectivity? connectivity}) + ConnectivityCubit({Connectivity? connectivity, bool simulateOffline = false}) : _connectivity = connectivity ?? Connectivity(), - super(const ConnectivityState.online()) { + _simulateOffline = simulateOffline, + super(ConnectivityState(hasNetworkConnection: true, isSimulatedOffline: simulateOffline)) { _init(); } final Connectivity _connectivity; StreamSubscription>? _subscription; + bool _simulateOffline; + bool _hasNetworkConnection = true; void _init() { _connectivity.checkConnectivity().then(_handleResults); _subscription = _connectivity.onConnectivityChanged.listen(_handleResults); } - void _handleResults(List results) { - final isOnline = results.any((r) => r != ConnectivityResult.none); - if (isOnline) { - emit(const ConnectivityState.online()); - } else { - emit(const ConnectivityState.offline()); + void setSimulatedOffline(bool value) { + if (_simulateOffline == value) { + return; } + + _simulateOffline = value; + _emitCurrentState(); + } + + void _handleResults(List results) { + _hasNetworkConnection = results.any((result) => result != ConnectivityResult.none); + _emitCurrentState(); + } + + void _emitCurrentState() { + emit(ConnectivityState(hasNetworkConnection: _hasNetworkConnection, isSimulatedOffline: _simulateOffline)); } @override diff --git a/lib/features/connectivity/cubit/connectivity_state.dart b/lib/features/connectivity/cubit/connectivity_state.dart --- a/lib/features/connectivity/cubit/connectivity_state.dart +++ b/lib/features/connectivity/cubit/connectivity_state.dart @@ -3,16 +3,22 @@ enum ConnectivityStatus { online, offline } class ConnectivityState extends Equatable { - const ConnectivityState({required this.isOnline}); + const ConnectivityState({required this.hasNetworkConnection, this.isSimulatedOffline = false}); - const ConnectivityState.online() : this(isOnline: true); + const ConnectivityState.online({bool isSimulatedOffline = false}) + : this(hasNetworkConnection: true, isSimulatedOffline: isSimulatedOffline); - const ConnectivityState.offline() : this(isOnline: false); + const ConnectivityState.offline({bool hasNetworkConnection = false, bool isSimulatedOffline = false}) + : this(hasNetworkConnection: hasNetworkConnection, isSimulatedOffline: isSimulatedOffline); - final bool isOnline; + final bool hasNetworkConnection; + final bool isSimulatedOffline; + + bool get isOnline => hasNetworkConnection && !isSimulatedOffline; + bool get isOffline => !isOnline; ConnectivityStatus get status => isOnline ? ConnectivityStatus.online : ConnectivityStatus.offline; @override - List get props => [isOnline]; + List get props => [hasNetworkConnection, isSimulatedOffline]; } diff --git a/lib/features/connectivity/presentation/connectivity_banner_host.dart b/lib/features/connectivity/presentation/connectivity_banner_host.dart new file mode 100644 --- /dev/null +++ b/lib/features/connectivity/presentation/connectivity_banner_host.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; + +class ConnectivityBannerHost extends StatelessWidget { + const ConnectivityBannerHost({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Stack( + children: [ + Positioned.fill(child: child), + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: AnimatedSlide( + duration: const Duration(milliseconds: 200), + offset: state.isOffline ? Offset.zero : const Offset(0, -1), + child: IgnorePointer( + ignoring: true, + child: Padding( + padding: const EdgeInsets.all(12), + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).colorScheme.error), + ), + child: Row( + children: [ + Icon(Icons.cloud_off, color: Theme.of(context).colorScheme.onErrorContainer, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + state.isSimulatedOffline + ? 'You\'re offline (simulated in developer settings).' + : 'You\'re offline.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -1,17 +1,39 @@ +import 'dart:convert'; + import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; import 'package:bluesky/bluesky.dart'; +import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; class FeedRepository { - FeedRepository({required Bluesky bluesky, ModerationService? moderationService}) - : _bluesky = bluesky, - _moderationService = moderationService; + FeedRepository({ + required Bluesky bluesky, + required AppDatabase database, + required String accountDid, + ModerationService? moderationService, + }) : _bluesky = bluesky, + _database = database, + _accountDid = accountDid, + _moderationService = moderationService; final Bluesky _bluesky; + final AppDatabase _database; + final String _accountDid; final ModerationService? _moderationService; + + static const String timelineCacheKey = 'timeline'; + + static String cacheKeyForSavedFeed(SavedFeed feed) { + final feedType = feed.type; + if (feedType is SavedFeedTypeKnownValue && feedType.data == KnownSavedFeedType.timeline) { + return timelineCacheKey; + } + + return 'feed:${feed.value}'; + } Future getAuthorFeed({ required String actor, @@ -40,7 +62,9 @@ $headers: await _moderationService?.headersForRequest(), ); - return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); + final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); + await _cacheFirstPageIfNeeded(feedKey: timelineCacheKey, result: result, cursor: cursor); + return result; } Future getFeed({required AtUri feedUri, String? cursor, int limit = 50}) async { @@ -51,7 +75,24 @@ $headers: await _moderationService?.headersForRequest(), ); - return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); + final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); + await _cacheFirstPageIfNeeded(feedKey: 'feed:${feedUri.toString()}', result: result, cursor: cursor); + return result; + } + + Future getCachedFeedPage(String feedKey) async { + final cached = await _database.getCachedFeedPage(_accountDid, feedKey); + if (cached == null) { + return null; + } + + final decoded = jsonDecode(cached.payload) as Map; + final rawPosts = decoded['posts'] as List? ?? const []; + final posts = rawPosts + .map((entry) => FeedViewPost.fromJson(Map.from(entry as Map))) + .toList(growable: false); + + return FeedResult(posts: posts, cursor: decoded['cursor'] as String?); } Future getPreferences() async { @@ -97,6 +138,25 @@ } return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); + } + + Future _cacheFirstPageIfNeeded({ + required String feedKey, + required FeedResult result, + required String? cursor, + }) async { + if (cursor != null) { + return; + } + + await _database.cacheFeedPage( + accountDid: _accountDid, + feedKey: feedKey, + payload: jsonEncode({ + 'cursor': result.cursor, + 'posts': result.posts.map((post) => post.toJson()).toList(growable: false), + }), + ); } } diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; @@ -73,6 +75,7 @@ } final pinnedFeeds = prefsState.pinnedFeeds; + final isOffline = context.select((cubit) => cubit.state.isOffline); if (pinnedFeeds.isEmpty) { return Scaffold( @@ -134,7 +137,8 @@ ), floatingActionButton: FloatingActionButton( heroTag: 'home-compose-fab', - onPressed: () => context.push('/compose'), + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', + onPressed: isOffline ? null : () => context.push('/compose'), shape: const CircleBorder(), child: const Icon(Icons.add), ), @@ -240,6 +244,7 @@ final List _posts = []; String? _cursor; bool _isLoading = false; + bool _showInitialLoading = false; bool _isLoadingMore = false; bool _hasError = false; String? _errorMessage; @@ -252,7 +257,7 @@ void initState() { super.initState(); _scrollController.addListener(_onScroll); - _loadFeed(); + _primeFeed(); } @override @@ -269,10 +274,31 @@ } Future _loadFeed() async { + await _loadFeedInternal(showLoading: _posts.isEmpty); + } + + Future _primeFeed() async { + final cachedResult = await _loadCachedFeed(); + if (cachedResult != null) { + _setStateIfMounted(() { + _posts + ..clear() + ..addAll(cachedResult.posts); + _cursor = cachedResult.cursor; + _hasError = false; + _errorMessage = null; + }); + } + + await _loadFeedInternal(showLoading: cachedResult == null); + } + + Future _loadFeedInternal({required bool showLoading}) async { if (_isLoading) return; _setStateIfMounted(() { _isLoading = true; + _showInitialLoading = showLoading; _hasError = false; _errorMessage = null; }); @@ -286,15 +312,29 @@ _posts.addAll(result.posts); _cursor = result.cursor; _isLoading = false; + _showInitialLoading = false; _hasError = false; }); } catch (e) { + if (_posts.isNotEmpty) { + _setStateIfMounted(() { + _isLoading = false; + _showInitialLoading = false; + }); + return; + } + _setStateIfMounted(() { _isLoading = false; + _showInitialLoading = false; _hasError = true; _errorMessage = e.toString(); }); } + } + + Future _loadCachedFeed() { + return context.read().getCachedFeedPage(FeedRepository.cacheKeyForSavedFeed(widget.feed)); } Future _loadMore() async { @@ -340,7 +380,7 @@ Widget build(BuildContext context) { super.build(context); - if (_isLoading) { + if (_showInitialLoading) { return const Center(child: CircularProgressIndicator()); } diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; @@ -755,6 +756,7 @@ } Widget _buildActionBar(BuildContext context, PostView post) { + final isOffline = context.select((cubit) => cubit.state.isOffline); return BlocBuilder( builder: (context, postActionState) { return BlocBuilder( @@ -782,6 +784,7 @@ unawaited(_onToggleSave(context)); }, onMore: () => _showMoreOptions(context), + isOffline: isOffline, ); }, ); diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -11,6 +11,8 @@ import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; @@ -422,6 +424,7 @@ Widget _buildProfileActions(BuildContext context, ProfileViewDetailed profile) { final viewer = profile.viewer; + final isOffline = context.select((cubit) => cubit.state.isOffline); return BlocProvider( create: (context) => ProfileActionCubit( @@ -451,6 +454,7 @@ isLoadingFollow: state.isLoadingFollow, isLoadingMute: state.isLoadingMute, isLoadingBlock: state.isLoadingBlock, + isOffline: isOffline, onFollow: () => context.read().toggleFollow(), onUnfollow: () => context.read().toggleFollow(), onMute: () => context.read().toggleMute(), @@ -593,10 +597,14 @@ final currentUserDid = context.read().state.tokens?.did; final isOwnProfile = profile.did == currentUserDid; final initialText = isOwnProfile ? null : '@${profile.handle} '; + final isOffline = context.select((cubit) => cubit.state.isOffline); return FloatingActionButton( heroTag: 'profile-compose-fab', - onPressed: () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', + onPressed: isOffline + ? null + : () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), child: const Icon(Icons.add), ); }, diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -7,6 +7,8 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lazurite/core/router/app_shell.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; @@ -904,25 +906,30 @@ @override Widget build(BuildContext context) { + final isOffline = context.select((cubit) => cubit.state.isOffline); if (_isFollowing) { - return OutlinedButton( - onPressed: _toggleFollow, + final button = OutlinedButton( + onPressed: isOffline ? null : _toggleFollow, style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), ), child: const Text('Following'), ); + + return isOffline ? Tooltip(message: offlineActionMessage('change your follow state'), child: button) : button; } - return FilledButton.tonal( - onPressed: _toggleFollow, + final button = FilledButton.tonal( + onPressed: isOffline ? null : _toggleFollow, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), ), child: const Text('Follow'), ); + + return isOffline ? Tooltip(message: offlineActionMessage('follow this account'), child: button) : button; } void _toggleFollow() { diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -13,6 +13,7 @@ bool? initialUseSystemTheme, UiDensity? initialUiDensity, FeedArchitecture? initialFeedArchitecture, + bool? initialSimulateOffline, int? initialThreadAutoCollapseDepth, }) : super( SettingsState( @@ -21,6 +22,7 @@ useSystemTheme: initialUseSystemTheme ?? false, uiDensity: initialUiDensity ?? UiDensity.standard, feedArchitecture: initialFeedArchitecture ?? FeedArchitecture.grid, + simulateOffline: initialSimulateOffline ?? false, threadAutoCollapseDepth: initialThreadAutoCollapseDepth, ), ); @@ -32,6 +34,7 @@ static const String _keyUseSystemTheme = 'use_system_theme'; static const String _keyUiDensity = 'ui_density'; static const String _keyFeedArchitecture = 'feed_architecture'; + static const String _keySimulateOffline = 'simulate_offline'; static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; Future loadSettings() async { @@ -40,6 +43,7 @@ final useSystemStr = await database.getSetting(_keyUseSystemTheme); final uiDensityStr = await database.getSetting(_keyUiDensity); final feedArchStr = await database.getSetting(_keyFeedArchitecture); + final simulateOfflineStr = await database.getSetting(_keySimulateOffline); final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); emit( @@ -49,6 +53,7 @@ useSystemTheme: useSystemStr == 'true', uiDensity: UiDensity.fromString(uiDensityStr), feedArchitecture: FeedArchitecture.fromString(feedArchStr), + simulateOffline: simulateOfflineStr == 'true', threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), ), ); @@ -83,6 +88,11 @@ Future setFeedArchitecture(FeedArchitecture architecture) async { await database.setSetting(_keyFeedArchitecture, architecture.name); emit(state.copyWith(feedArchitecture: architecture)); + } + + Future setSimulateOffline(bool value) async { + await database.setSetting(_keySimulateOffline, value.toString()); + emit(state.copyWith(simulateOffline: value)); } Future setThreadAutoCollapseDepth(int? depth) async { diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -14,6 +14,7 @@ required this.useSystemTheme, this.uiDensity = UiDensity.standard, this.feedArchitecture = FeedArchitecture.grid, + this.simulateOffline = false, this.threadAutoCollapseDepth, }); @@ -22,6 +23,7 @@ final bool useSystemTheme; final UiDensity uiDensity; final FeedArchitecture feedArchitecture; + final bool simulateOffline; final int? threadAutoCollapseDepth; ThemeData get themeData { @@ -35,6 +37,7 @@ bool? useSystemTheme, UiDensity? uiDensity, FeedArchitecture? feedArchitecture, + bool? simulateOffline, Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, }) { return SettingsState( @@ -43,6 +46,7 @@ useSystemTheme: useSystemTheme ?? this.useSystemTheme, uiDensity: uiDensity ?? this.uiDensity, feedArchitecture: feedArchitecture ?? this.feedArchitecture, + simulateOffline: simulateOffline ?? this.simulateOffline, threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) ? this.threadAutoCollapseDepth : threadAutoCollapseDepth as int?, @@ -56,6 +60,7 @@ useSystemTheme, uiDensity, feedArchitecture, + simulateOffline, threadAutoCollapseDepth, ]; } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -98,6 +99,11 @@ trailing: Switch(value: false, onChanged: (_) {}), ), const SizedBox(height: 24), + if (!kReleaseMode) ...[ + _buildSectionHeader(context, 'Developer'), + _buildDeveloperSettings(context), + const SizedBox(height: 24), + ], _buildSectionHeader(context, 'About'), _SettingsTile( icon: Icons.code_outlined, @@ -275,6 +281,30 @@ onChanged: settingsCubit.setThreadAutoCollapseDepth, ), ], + ), + ); + }, + ); + } + + Widget _buildDeveloperSettings(BuildContext context) { + final settingsCubit = context.read(); + + return BlocBuilder( + builder: (context, state) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + color: Theme.of(context).cardColor, + ), + child: _SettingsTile( + icon: Icons.cloud_off_outlined, + title: 'Simulate Offline', + subtitle: 'Force offline UI for testing network resilience', + trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), ), ); }, diff --git a/test/features/alerts/presentation/alerts_screen_test.dart b/test/features/alerts/presentation/alerts_screen_test.dart --- a/test/features/alerts/presentation/alerts_screen_test.dart +++ b/test/features/alerts/presentation/alerts_screen_test.dart @@ -3,11 +3,13 @@ import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; import 'package:bluesky/chat_bsky_actor_defs.dart' as chat_actor; import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/data/convo_repository.dart'; import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; @@ -19,13 +21,23 @@ class MockConvoRepository extends Mock implements ConvoRepository {} +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + void main() { late MockNotificationRepository notificationRepository; late MockConvoRepository convoRepository; + late MockConnectivityCubit connectivityCubit; setUp(() { notificationRepository = MockNotificationRepository(); convoRepository = MockConvoRepository(); + connectivityCubit = MockConnectivityCubit(); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); when( () => notificationRepository.listNotifications( @@ -121,6 +133,7 @@ BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), + BlocProvider.value(value: connectivityCubit), RepositoryProvider.value(value: 'did:plc:me'), ], child: const AlertsScreen(), @@ -133,6 +146,7 @@ BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), + BlocProvider.value(value: connectivityCubit), RepositoryProvider.value(value: 'did:plc:me'), ], child: const AlertsScreen(initialTab: AlertsTab.messages), @@ -145,6 +159,7 @@ BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), + BlocProvider.value(value: connectivityCubit), RepositoryProvider.value(value: 'did:plc:me'), ], child: const AlertsScreen(initialTab: AlertsTab.requests), diff --git a/test/features/compose/presentation/compose_screen_test.dart b/test/features/compose/presentation/compose_screen_test.dart --- a/test/features/compose/presentation/compose_screen_test.dart +++ b/test/features/compose/presentation/compose_screen_test.dart @@ -3,11 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:lazurite/features/compose/presentation/compose_screen.dart'; import 'package:mocktail/mocktail.dart'; class MockComposeBloc extends MockBloc implements ComposeBloc {} + +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} class FakeDraftsCompanion extends Fake implements DraftsCompanion {} @@ -28,17 +31,33 @@ void main() { late MockComposeBloc mockBloc; + late MockConnectivityCubit connectivityCubit; setUp(() { registerFallbackValue(FakeDraftsCompanion()); registerFallbackValue(const TextChanged('')); mockBloc = MockComposeBloc(); + connectivityCubit = MockConnectivityCubit(); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); }); - tearDown(() => mockBloc.close()); + tearDown(() { + mockBloc.close(); + }); Widget buildSubject() => MaterialApp( - home: BlocProvider.value(value: mockBloc, child: const ComposeScreen()), + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: mockBloc), + BlocProvider.value(value: connectivityCubit), + ], + child: const ComposeScreen(), + ), ); void seedState(ComposeState state) { @@ -79,8 +98,11 @@ await tester.pumpWidget( MaterialApp( - home: BlocProvider.value( - value: mockBloc, + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: mockBloc), + BlocProvider.value(value: connectivityCubit), + ], child: const ComposeScreen(initialText: '@river.bsky.social '), ), ), diff --git a/test/features/connectivity/cubit/connectivity_cubit_test.dart b/test/features/connectivity/cubit/connectivity_cubit_test.dart --- a/test/features/connectivity/cubit/connectivity_cubit_test.dart +++ b/test/features/connectivity/cubit/connectivity_cubit_test.dart @@ -74,5 +74,18 @@ expect(state.isOnline, isFalse); expect(state.status, ConnectivityStatus.offline); }); + + blocTest( + 'setSimulatedOffline forces offline until cleared', + build: () => ConnectivityCubit(connectivity: mockConnectivity), + act: (cubit) { + cubit.setSimulatedOffline(true); + cubit.setSimulatedOffline(false); + }, + expect: () => [ + predicate((state) => state.isOffline && state.isSimulatedOffline), + predicate((state) => state.isOnline && !state.isSimulatedOffline), + ], + ); }); } diff --git a/test/features/feed/presentation/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -8,6 +8,7 @@ import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_architecture.dart'; import 'package:lazurite/core/theme/ui_density.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; @@ -21,6 +22,8 @@ class MockFeedPreferencesCubit extends MockCubit implements FeedPreferencesCubit {} class MockFeedRepository extends Mock implements FeedRepository {} + +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( themePalette: AppThemePalette.oxocarbon, @@ -70,10 +73,24 @@ required FeedPreferencesCubit feedPreferencesCubit, required FeedRepository feedRepository, }) { + final connectivityCubit = MockConnectivityCubit(); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); + return MaterialApp( home: RepositoryProvider.value( value: feedRepository, - child: BlocProvider.value(value: feedPreferencesCubit, child: const HomeFeedScreen()), + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: feedPreferencesCubit), + BlocProvider.value(value: connectivityCubit), + ], + child: const HomeFeedScreen(), + ), ), ); } @@ -299,6 +316,7 @@ when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); whenListen(feedPreferencesCubit, const Stream.empty(), initialState: _homeFeedState); + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); when( () => feedRepository.getTimeline( cursor: any(named: 'cursor'), @@ -327,6 +345,7 @@ when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); whenListen(feedPreferencesCubit, const Stream.empty(), initialState: _homeFeedState); + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); when( () => feedRepository.getTimeline( cursor: any(named: 'cursor'), diff --git a/test/features/feed/presentation/post_action_bar_test.dart b/test/features/feed/presentation/post_action_bar_test.dart --- a/test/features/feed/presentation/post_action_bar_test.dart +++ b/test/features/feed/presentation/post_action_bar_test.dart @@ -18,6 +18,7 @@ VoidCallback? onRepost, VoidCallback? onLike, VoidCallback? onReply, + bool isOffline = false, }) { return MaterialApp( home: Scaffold( @@ -38,6 +39,7 @@ onRepost: onRepost, onLike: onLike, onReply: onReply, + isOffline: isOffline, ), ), ); @@ -148,6 +150,30 @@ await tester.pumpAndSettle(); expect(cloudUnsaveCalled, isTrue); + }); + + testWidgets('offline mode disables reply, repost, and like actions', (tester) async { + var replyCalled = false; + var repostCalled = false; + var likeCalled = false; + + await tester.pumpWidget( + _buildBar( + isOffline: true, + onReply: () => replyCalled = true, + onRepost: () => repostCalled = true, + onLike: () => likeCalled = true, + ), + ); + + await tester.tap(find.byIcon(Icons.chat_bubble_outline)); + await tester.tap(find.byIcon(Icons.repeat)); + await tester.tap(find.byIcon(Icons.favorite_outline)); + await tester.pump(); + + expect(replyCalled, isFalse); + expect(repostCalled, isFalse); + expect(likeCalled, isFalse); }); }); } diff --git a/test/features/feed/presentation/post_thread_screen_test.dart b/test/features/feed/presentation/post_thread_screen_test.dart --- a/test/features/feed/presentation/post_thread_screen_test.dart +++ b/test/features/feed/presentation/post_thread_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; @@ -14,6 +15,8 @@ class MockPostActionRepository extends Mock implements PostActionRepository {} class MockSavedPostsCubit extends MockCubit implements SavedPostsCubit {} + +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} PostView _makePost({ required String did, @@ -52,6 +55,7 @@ required this.thread, required this.savedPostsCubit, required this.postActionRepository, + required this.connectivityCubit, this.initialCollapsedUris = const {}, this.onContinueThread, }); @@ -59,6 +63,7 @@ final ThreadViewPost thread; final SavedPostsCubit savedPostsCubit; final PostActionRepository postActionRepository; + final ConnectivityCubit connectivityCubit; final Set initialCollapsedUris; final ValueChanged? onContinueThread; @@ -85,24 +90,27 @@ ], child: BlocProvider.value( value: widget.savedPostsCubit, - child: Scaffold( - body: SingleChildScrollView( - child: ThreadReplyNode( - thread: widget.thread, - depth: 1, - accountDid: 'did:plc:current', - opDid: 'did:plc:op', - collapsedUris: collapsedUris, - onToggleCollapse: (postUri) { - setState(() { - if (collapsedUris.contains(postUri)) { - collapsedUris.remove(postUri); - } else { - collapsedUris.add(postUri); - } - }); - }, - onContinueThread: widget.onContinueThread, + child: BlocProvider.value( + value: widget.connectivityCubit, + child: Scaffold( + body: SingleChildScrollView( + child: ThreadReplyNode( + thread: widget.thread, + depth: 1, + accountDid: 'did:plc:current', + opDid: 'did:plc:op', + collapsedUris: collapsedUris, + onToggleCollapse: (postUri) { + setState(() { + if (collapsedUris.contains(postUri)) { + collapsedUris.remove(postUri); + } else { + collapsedUris.add(postUri); + } + }); + }, + onContinueThread: widget.onContinueThread, + ), ), ), ), @@ -115,14 +123,22 @@ void main() { late MockPostActionRepository mockPostActionRepository; late MockSavedPostsCubit mockSavedPostsCubit; + late MockConnectivityCubit mockConnectivityCubit; setUp(() { mockPostActionRepository = MockPostActionRepository(); mockSavedPostsCubit = MockSavedPostsCubit(); + mockConnectivityCubit = MockConnectivityCubit(); const savedState = SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {}); when(() => mockSavedPostsCubit.state).thenReturn(savedState); whenListen(mockSavedPostsCubit, const Stream.empty(), initialState: savedState); + when(() => mockConnectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + mockConnectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); }); testWidgets('renders nested threaded replies recursively', (tester) async { @@ -152,6 +168,7 @@ thread: parent, savedPostsCubit: mockSavedPostsCubit, postActionRepository: mockPostActionRepository, + connectivityCubit: mockConnectivityCubit, ), ); await tester.pumpAndSettle(); @@ -190,6 +207,7 @@ thread: parent, savedPostsCubit: mockSavedPostsCubit, postActionRepository: mockPostActionRepository, + connectivityCubit: mockConnectivityCubit, ), ); await tester.pumpAndSettle(); @@ -227,6 +245,7 @@ thread: parent, savedPostsCubit: mockSavedPostsCubit, postActionRepository: mockPostActionRepository, + connectivityCubit: mockConnectivityCubit, ), ); await tester.pumpAndSettle(); @@ -278,6 +297,7 @@ thread: depth1, savedPostsCubit: mockSavedPostsCubit, postActionRepository: mockPostActionRepository, + connectivityCubit: mockConnectivityCubit, onContinueThread: (thread) => continuedThread = thread, ), ); @@ -397,6 +417,7 @@ thread: depth1, savedPostsCubit: mockSavedPostsCubit, postActionRepository: mockPostActionRepository, + connectivityCubit: mockConnectivityCubit, initialCollapsedUris: computeInitialCollapsedThreadUris(root, autoCollapseDepth: 2), ), ); diff --git a/test/features/feed/presentation/saved_posts_screen_test.dart b/test/features/feed/presentation/saved_posts_screen_test.dart --- a/test/features/feed/presentation/saved_posts_screen_test.dart +++ b/test/features/feed/presentation/saved_posts_screen_test.dart @@ -4,11 +4,13 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; @@ -18,6 +20,8 @@ class MockAppDatabase extends Mock implements AppDatabase {} class MockPostActionRepository extends Mock implements PostActionRepository {} + +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} PostView _makePostView({ String did = 'did:plc:author', @@ -52,12 +56,20 @@ void main() { late MockAppDatabase mockDatabase; late MockPostActionRepository mockPostActionRepository; + late MockConnectivityCubit connectivityCubit; const testAccountDid = 'did:plc:me'; setUp(() { mockDatabase = MockAppDatabase(); mockPostActionRepository = MockPostActionRepository(); + connectivityCubit = MockConnectivityCubit(); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); when(() => mockDatabase.watchSavedPostsWithType(testAccountDid)).thenAnswer((_) => Stream.value({})); when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); @@ -77,7 +89,10 @@ RepositoryProvider(create: (_) => PostActionCache()), RepositoryProvider.value(value: testAccountDid), ], - child: const MaterialApp(home: SavedPostsScreen(accountDid: testAccountDid)), + child: BlocProvider.value( + value: connectivityCubit, + child: const MaterialApp(home: SavedPostsScreen(accountDid: testAccountDid)), + ), ); } diff --git a/test/features/messages/presentation/convo_list_screen_test.dart b/test/features/messages/presentation/convo_list_screen_test.dart --- a/test/features/messages/presentation/convo_list_screen_test.dart +++ b/test/features/messages/presentation/convo_list_screen_test.dart @@ -1,8 +1,10 @@ import 'package:bluesky/chat_bsky_actor_defs.dart'; import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/data/convo_repository.dart'; import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; @@ -10,13 +12,23 @@ class MockConvoRepository extends Mock implements ConvoRepository {} +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + void main() { const currentUserDid = 'did:plc:me'; late MockConvoRepository mockRepository; + late MockConnectivityCubit connectivityCubit; setUp(() { mockRepository = MockConvoRepository(); + connectivityCubit = MockConnectivityCubit(); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); }); ProfileViewBasic makeProfile({String did = 'did:plc:other', String handle = 'other.bsky.social'}) => @@ -39,7 +51,7 @@ child: MaterialApp( home: BlocProvider( create: (_) => ConvoListBloc(convoRepository: mockRepository), - child: const ConvoListScreen(), + child: BlocProvider.value(value: connectivityCubit, child: const ConvoListScreen()), ), ), ); @@ -102,6 +114,27 @@ await tester.pumpAndSettle(); expect(find.text('No conversations yet'), findsOneWidget); + }); + + testWidgets('shows offline empty state when offline with no conversations', (tester) async { + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.offline()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.offline(), + ); + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('No connection'), findsOneWidget); + expect(find.text('Reconnect to load messages.'), findsOneWidget); }); testWidgets('shows error state on failure', (tester) async { diff --git a/test/features/notifications/presentation/notifications_screen_test.dart b/test/features/notifications/presentation/notifications_screen_test.dart --- a/test/features/notifications/presentation/notifications_screen_test.dart +++ b/test/features/notifications/presentation/notifications_screen_test.dart @@ -1,9 +1,11 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; @@ -14,12 +16,22 @@ class MockNotificationRepository extends Mock implements NotificationRepository {} +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + void main() { group('NotificationsScreen', () { late MockNotificationRepository mockNotificationRepository; + late MockConnectivityCubit connectivityCubit; setUp(() { mockNotificationRepository = MockNotificationRepository(); + connectivityCubit = MockConnectivityCubit(); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); when( () => mockNotificationRepository.listNotifications( cursor: any(named: 'cursor'), @@ -40,6 +52,7 @@ BlocProvider( create: (_) => UnreadCountCubit(notificationRepository: mockNotificationRepository), ), + BlocProvider.value(value: connectivityCubit), ], child: const NotificationsScreen(), ), @@ -88,6 +101,21 @@ await tester.pumpAndSettle(); expect(find.text('No notifications yet'), findsOneWidget); + }); + + testWidgets('displays offline empty state when offline with no notifications', (tester) async { + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.offline()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.offline(), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('No connection'), findsOneWidget); + expect(find.text('Reconnect to load notifications.'), findsOneWidget); }); testWidgets('displays error state on failure', (tester) async { diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -15,6 +15,7 @@ import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; @@ -37,6 +38,8 @@ class MockSettingsCubit extends MockCubit implements SettingsCubit {} +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + class MockPostActionRepository extends Mock implements PostActionRepository {} class MockSavedPostsCubit extends MockCubit implements SavedPostsCubit {} @@ -50,6 +53,7 @@ late MockProfileBloc profileBloc; late MockFeedBloc feedBloc; late MockSettingsCubit settingsCubit; + late MockConnectivityCubit connectivityCubit; const tokens = AuthTokens( accessToken: 'access', @@ -92,6 +96,7 @@ profileBloc = MockProfileBloc(); feedBloc = MockFeedBloc(); settingsCubit = MockSettingsCubit(); + connectivityCubit = MockConnectivityCubit(); when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); @@ -99,6 +104,7 @@ const FeedState.loaded(actor: 'did:plc:me', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), ); when(() => settingsCubit.state).thenReturn(defaultSettingsState()); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); whenListen(profileBloc, const Stream.empty(), initialState: ProfileState.loaded(profile: profile)); @@ -113,6 +119,11 @@ ), ); whenListen(settingsCubit, const Stream.empty(), initialState: defaultSettingsState()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); }); Widget buildSubject() { @@ -122,6 +133,7 @@ BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), ], child: const MaterialApp(home: ProfileScreen()), ); @@ -190,6 +202,7 @@ BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), ], child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), ), @@ -245,6 +258,7 @@ BlocProvider.value(value: authBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), + BlocProvider.value(value: connectivityCubit), BlocProvider.value(value: settingsCubit), ], child: const ProfileScreen(actor: 'did:plc:other', showBackButton: true), @@ -405,6 +419,7 @@ BlocProvider.value(value: authBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), + BlocProvider.value(value: connectivityCubit), BlocProvider.value(value: settCubit), BlocProvider.value(value: mockSavedPostsCubit), ], @@ -497,6 +512,7 @@ BlocProvider.value(value: authBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), + BlocProvider.value(value: connectivityCubit), BlocProvider.value(value: settingsCubit), ], child: MultiRepositoryProvider( @@ -538,6 +554,7 @@ BlocProvider.value(value: authBloc), BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), + BlocProvider.value(value: connectivityCubit), BlocProvider.value(value: settingsCubit), ], child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), diff --git a/test/features/search/presentation/search_screen_test.dart b/test/features/search/presentation/search_screen_test.dart --- a/test/features/search/presentation/search_screen_test.dart +++ b/test/features/search/presentation/search_screen_test.dart @@ -1,11 +1,13 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; import 'package:lazurite/features/search/presentation/search_screen.dart'; @@ -15,14 +17,24 @@ class MockAppDatabase extends Mock implements AppDatabase {} +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + void main() { group('SearchScreen', () { late MockSearchRepository mockSearchRepository; late MockAppDatabase mockDatabase; + late MockConnectivityCubit connectivityCubit; setUp(() { mockSearchRepository = MockSearchRepository(); mockDatabase = MockAppDatabase(); + connectivityCubit = MockConnectivityCubit(); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); when( () => mockSearchRepository.searchPosts( @@ -49,9 +61,17 @@ Widget buildSubject() { return MaterialApp( - home: BlocProvider( - create: (_) => - SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), + home: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SearchBloc( + searchRepository: mockSearchRepository, + database: mockDatabase, + accountDid: 'did:plc:test', + ), + ), + BlocProvider.value(value: connectivityCubit), + ], child: const SearchScreen(), ), ); @@ -68,7 +88,7 @@ database: mockDatabase, accountDid: 'did:plc:test', ), - child: const SearchScreen(), + child: BlocProvider.value(value: connectivityCubit, child: const SearchScreen()), ), ), GoRoute( @@ -159,9 +179,17 @@ await tester.pumpWidget( MaterialApp( - home: BlocProvider( - create: (_) => - SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), + home: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SearchBloc( + searchRepository: mockSearchRepository, + database: mockDatabase, + accountDid: 'did:plc:test', + ), + ), + BlocProvider.value(value: connectivityCubit), + ], child: const SearchScreen(), ), ), @@ -194,9 +222,17 @@ await tester.pumpWidget( MaterialApp( - home: BlocProvider( - create: (_) => - SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), + home: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SearchBloc( + searchRepository: mockSearchRepository, + database: mockDatabase, + accountDid: 'did:plc:test', + ), + ), + BlocProvider.value(value: connectivityCubit), + ], child: const SearchScreen(), ), ), diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -27,6 +27,7 @@ expect(cubit.state.useSystemTheme, false); expect(cubit.state.uiDensity, UiDensity.standard); expect(cubit.state.feedArchitecture, FeedArchitecture.grid); + expect(cubit.state.simulateOffline, false); expect(cubit.state.threadAutoCollapseDepth, isNull); }); @@ -38,6 +39,7 @@ initialUseSystemTheme: true, initialUiDensity: UiDensity.compact, initialFeedArchitecture: FeedArchitecture.linear, + initialSimulateOffline: true, initialThreadAutoCollapseDepth: 3, ); expect(cubit.state.themePalette, AppThemePalette.catppuccin); @@ -45,6 +47,7 @@ expect(cubit.state.useSystemTheme, true); expect(cubit.state.uiDensity, UiDensity.compact); expect(cubit.state.feedArchitecture, FeedArchitecture.linear); + expect(cubit.state.simulateOffline, true); expect(cubit.state.threadAutoCollapseDepth, 3); }); @@ -57,6 +60,7 @@ await database.setSetting('use_system_theme', 'true'); await database.setSetting('ui_density', 'compact'); await database.setSetting('feed_architecture', 'linear'); + await database.setSetting('simulate_offline', 'true'); await database.setSetting('thread_auto_collapse_depth', '4'); }, act: (cubit) => cubit.loadSettings(), @@ -67,6 +71,7 @@ .having((s) => s.useSystemTheme, 'useSystemTheme', true) .having((s) => s.uiDensity, 'uiDensity', UiDensity.compact) .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) + .having((s) => s.simulateOffline, 'simulateOffline', true) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), ], ); @@ -82,6 +87,7 @@ .having((s) => s.useSystemTheme, 'useSystemTheme', false) .having((s) => s.uiDensity, 'uiDensity', UiDensity.standard) .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid) + .having((s) => s.simulateOffline, 'simulateOffline', false) .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), ], ); @@ -177,6 +183,17 @@ verify: (cubit) async { final value = await database.getSetting('feed_architecture'); expect(value, 'grid'); + }, + ); + + blocTest( + 'setSimulateOffline updates state and persists to database', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setSimulateOffline(true), + expect: () => [isA().having((s) => s.simulateOffline, 'simulateOffline', true)], + verify: (cubit) async { + final value = await database.getSetting('simulate_offline'); + expect(value, 'true'); }, ); diff --git a/test/features/settings/bloc/settings_state_test.dart b/test/features/settings/bloc/settings_state_test.dart --- a/test/features/settings/bloc/settings_state_test.dart +++ b/test/features/settings/bloc/settings_state_test.dart @@ -100,6 +100,23 @@ expect(state1, isNot(equals(state2))); }); + test('inequality when simulateOffline differs', () { + const state1 = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + simulateOffline: false, + ); + const state2 = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + simulateOffline: true, + ); + + expect(state1, isNot(equals(state2))); + }); + test('inequality when threadAutoCollapseDepth differs', () { const state1 = SettingsState( themePalette: AppThemePalette.oxocarbon, @@ -130,6 +147,7 @@ useSystemTheme: true, uiDensity: UiDensity.compact, feedArchitecture: FeedArchitecture.linear, + simulateOffline: true, threadAutoCollapseDepth: 3, ); @@ -138,6 +156,7 @@ expect(updated.useSystemTheme, true); expect(updated.uiDensity, UiDensity.compact); expect(updated.feedArchitecture, FeedArchitecture.linear); + expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 3); expect(original.themePalette, AppThemePalette.oxocarbon); }); @@ -149,6 +168,7 @@ useSystemTheme: true, uiDensity: UiDensity.relaxed, feedArchitecture: FeedArchitecture.linear, + simulateOffline: true, threadAutoCollapseDepth: 4, ); @@ -159,6 +179,7 @@ expect(updated.useSystemTheme, true); expect(updated.uiDensity, UiDensity.relaxed); expect(updated.feedArchitecture, FeedArchitecture.linear); + expect(updated.simulateOffline, true); expect(updated.threadAutoCollapseDepth, 4); }); @@ -182,6 +203,7 @@ useSystemTheme: true, uiDensity: UiDensity.compact, feedArchitecture: FeedArchitecture.linear, + simulateOffline: true, threadAutoCollapseDepth: 6, ); @@ -190,6 +212,7 @@ expect(state.props, contains(true)); expect(state.props, contains(UiDensity.compact)); expect(state.props, contains(FeedArchitecture.linear)); + expect(state.props, contains(true)); expect(state.props, contains(6)); }); @@ -209,6 +232,15 @@ useSystemTheme: false, ); expect(state.feedArchitecture, FeedArchitecture.grid); + }); + + test('defaults simulateOffline to false', () { + const state = SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ); + expect(state.simulateOffline, isFalse); }); test('defaults threadAutoCollapseDepth to null', () { diff --git a/lib/features/feed/presentation/widgets/post_action_bar.dart b/lib/features/feed/presentation/widgets/post_action_bar.dart --- a/lib/features/feed/presentation/widgets/post_action_bar.dart +++ b/lib/features/feed/presentation/widgets/post_action_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:share_plus/share_plus.dart'; class PostActionBar extends StatelessWidget { @@ -28,6 +29,7 @@ this.onMore, this.isLoadingLike = false, this.isLoadingRepost = false, + this.isOffline = false, }); final int replyCount; @@ -52,6 +54,7 @@ final VoidCallback? onMore; final bool isLoadingLike; final bool isLoadingRepost; + final bool isOffline; @override Widget build(BuildContext context) { @@ -62,7 +65,8 @@ icon: Icons.chat_bubble_outline, activeIcon: Icons.chat_bubble, count: replyCount, - onTap: onReply, + onTap: isOffline ? null : onReply, + tooltip: isOffline ? offlineActionMessage('reply to this post') : null, color: Theme.of(context).colorScheme.onSurfaceVariant, ), _ActionButton( @@ -71,9 +75,10 @@ count: repostCount, isActive: isReposted, isLoading: isLoadingRepost, - onTap: onRepost, + onTap: isOffline ? null : onRepost, activeColor: Colors.green, - onLongPress: onRepost != null ? () => _showRepostOptions(context) : null, + onLongPress: !isOffline && onRepost != null ? () => _showRepostOptions(context) : null, + tooltip: isOffline ? offlineActionMessage('repost this post') : null, ), _ActionButton( icon: Icons.favorite_outline, @@ -81,8 +86,9 @@ count: likeCount, isActive: isLiked, isLoading: isLoadingLike, - onTap: onLike, + onTap: isOffline ? null : onLike, activeColor: Colors.pink, + tooltip: isOffline ? offlineActionMessage('like this post') : null, ), _ActionButton( icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, @@ -222,6 +228,7 @@ this.onLongPress, this.color, this.activeColor, + this.tooltip, }); final IconData icon; @@ -233,6 +240,7 @@ final VoidCallback? onLongPress; final Color? color; final Color? activeColor; + final String? tooltip; @override Widget build(BuildContext context) { @@ -267,6 +275,10 @@ onLongPress: onLongPress, child: AnimatedScale(scale: isActive ? 1.0 : 1.0, duration: const Duration(milliseconds: 100), child: button), ); + } + + if (tooltip != null) { + button = Tooltip(message: tooltip!, child: button); } return button; diff --git a/lib/features/feed/presentation/widgets/post_card_footer.dart b/lib/features/feed/presentation/widgets/post_card_footer.dart --- a/lib/features/feed/presentation/widgets/post_card_footer.dart +++ b/lib/features/feed/presentation/widgets/post_card_footer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; /// Formats a post timestamp as a short, uppercase string. String formatPostTime(DateTime time) { @@ -38,6 +39,7 @@ this.onCloudSave, this.onCloudUnsave, this.showCounts = false, + this.isOffline = false, }); final String timestamp; @@ -59,6 +61,7 @@ final VoidCallback? onCloudSave; final VoidCallback? onCloudUnsave; final bool showCounts; + final bool isOffline; @override Widget build(BuildContext context) { @@ -80,11 +83,12 @@ isActive: false, isLoading: false, count: replyCount, - onTap: onReply, + onTap: isOffline ? null : onReply, color: colorScheme.onSurfaceVariant, iconSize: iconSize, padding: actionPadding, showCount: canShowCounts, + tooltip: isOffline ? offlineActionMessage('reply to this post') : null, ), _FooterAction( icon: Icons.repeat, @@ -92,12 +96,13 @@ isActive: isReposted, isLoading: isLoadingRepost, count: repostCount, - onTap: onRepost, + onTap: isOffline ? null : onRepost, color: colorScheme.onSurfaceVariant, activeColor: Colors.green, iconSize: iconSize, padding: actionPadding, showCount: canShowCounts, + tooltip: isOffline ? offlineActionMessage('repost this post') : null, ), _FooterAction( icon: Icons.favorite_outline, @@ -105,12 +110,13 @@ isActive: isLiked, isLoading: isLoadingLike, count: likeCount, - onTap: onLike, + onTap: isOffline ? null : onLike, color: colorScheme.onSurfaceVariant, activeColor: Colors.pink, iconSize: iconSize, padding: actionPadding, showCount: canShowCounts, + tooltip: isOffline ? offlineActionMessage('like this post') : null, ), _FooterAction( icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, @@ -231,6 +237,7 @@ this.onLongPress, this.color, this.activeColor, + this.tooltip, }); final IconData icon; @@ -245,13 +252,14 @@ final VoidCallback? onLongPress; final Color? color; final Color? activeColor; + final String? tooltip; @override Widget build(BuildContext context) { final defaultColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; final iconColor = isActive ? (activeColor ?? defaultColor) : defaultColor; - return InkWell( + Widget button = InkWell( onTap: isLoading ? null : onTap, onLongPress: onLongPress, borderRadius: BorderRadius.zero, @@ -276,6 +284,12 @@ ), ), ); + + if (tooltip != null) { + button = Tooltip(message: tooltip!, child: button); + } + + return button; } String _formatCount(int count) { diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; @@ -139,6 +140,7 @@ Widget _buildFooter(BuildContext context) { final post = feedViewPost.post; + final isOffline = context.select((cubit) => cubit.state.isOffline); return BlocBuilder( builder: (context, postActionState) { return BlocBuilder( @@ -163,6 +165,7 @@ onCloudSave: () => unawaited(_onCloudSave(context)), onCloudUnsave: () => unawaited(_onCloudUnsave(context)), showCounts: true, + isOffline: isOffline, ); }, ); diff --git a/lib/features/messages/presentation/widgets/convo_list_pane.dart b/lib/features/messages/presentation/widgets/convo_list_pane.dart --- a/lib/features/messages/presentation/widgets/convo_list_pane.dart +++ b/lib/features/messages/presentation/widgets/convo_list_pane.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; @@ -71,12 +72,19 @@ Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + final isOffline = context.select((cubit) => cubit.state.isOffline); if (state.status == ConvoListStatus.initial || (state.status == ConvoListStatus.loading && state.convos.isEmpty)) { + if (isOffline) { + return const _OfflineConvoState(); + } return const Center(child: CircularProgressIndicator()); } if (state.status == ConvoListStatus.error && state.convos.isEmpty) { + if (isOffline) { + return const _OfflineConvoState(); + } return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -97,6 +105,9 @@ final filtered = _filteredConvos(state.convos, widget.tab); if (filtered.isEmpty) { + if (isOffline) { + return const _OfflineConvoState(); + } return RefreshIndicator( onRefresh: _onRefresh, child: ListView( @@ -153,5 +164,32 @@ final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; final title = other?.displayName ?? other?.handle ?? 'Conversation'; context.push('/alerts/messages/${convo.id}', extra: MessageThreadRouteArgs(title: title)); + } +} + +class _OfflineConvoState extends StatelessWidget { + const _OfflineConvoState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off_outlined, size: 48, color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 12), + Text('No connection', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Reconnect to load messages.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); } } diff --git a/lib/features/notifications/presentation/widgets/notifications_pane.dart b/lib/features/notifications/presentation/widgets/notifications_pane.dart --- a/lib/features/notifications/presentation/widgets/notifications_pane.dart +++ b/lib/features/notifications/presentation/widgets/notifications_pane.dart @@ -1,6 +1,7 @@ import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; @@ -51,12 +52,19 @@ Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + final isOffline = context.select((cubit) => cubit.state.isOffline); if (state.status == NotificationStatus.initial || (state.status == NotificationStatus.loading && state.notifications.isEmpty)) { + if (isOffline) { + return const _OfflineNotificationsState(); + } return const Center(child: CircularProgressIndicator()); } if (state.status == NotificationStatus.error && state.notifications.isEmpty) { + if (isOffline) { + return const _OfflineNotificationsState(); + } return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -75,6 +83,9 @@ } if (state.notifications.isEmpty) { + if (isOffline) { + return const _OfflineNotificationsState(); + } return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); } @@ -211,6 +222,33 @@ ]; return '${months[date.month - 1]} ${date.day}'; + } +} + +class _OfflineNotificationsState extends StatelessWidget { + const _OfflineNotificationsState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off_outlined, size: 48, color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 12), + Text('No connection', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Reconnect to load notifications.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); } } diff --git a/lib/features/profile/presentation/widgets/profile_action_buttons.dart b/lib/features/profile/presentation/widgets/profile_action_buttons.dart --- a/lib/features/profile/presentation/widgets/profile_action_buttons.dart +++ b/lib/features/profile/presentation/widgets/profile_action_buttons.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; class ProfileActionButtons extends StatelessWidget { const ProfileActionButtons({ @@ -11,6 +12,7 @@ required this.isLoadingFollow, required this.isLoadingMute, required this.isLoadingBlock, + this.isOffline = false, this.onFollow, this.onUnfollow, this.onMute, @@ -28,6 +30,7 @@ final bool isLoadingFollow; final bool isLoadingMute; final bool isLoadingBlock; + final bool isOffline; final VoidCallback? onFollow; final VoidCallback? onUnfollow; final VoidCallback? onMute; @@ -53,23 +56,30 @@ if (isBlocked) { return _ActionButton( label: 'Unblock', - onPressed: onUnblock != null ? () => _confirmUnblock(context) : null, + onPressed: isOffline || onUnblock == null ? null : () => _confirmUnblock(context), isLoading: isLoadingBlock, foregroundColor: Theme.of(context).colorScheme.onError, backgroundColor: Theme.of(context).colorScheme.error, + tooltip: isOffline ? offlineActionMessage('unblock this account') : null, ); } if (isFollowing) { return _ActionButton( label: 'Following', - onPressed: onUnfollow != null ? () => _confirmUnfollow(context) : null, + onPressed: isOffline || onUnfollow == null ? null : () => _confirmUnfollow(context), isLoading: isLoadingFollow, isSecondary: true, + tooltip: isOffline ? offlineActionMessage('change your follow state') : null, ); } - return _ActionButton(label: 'Follow', onPressed: onFollow, isLoading: isLoadingFollow); + return _ActionButton( + label: 'Follow', + onPressed: isOffline ? null : onFollow, + isLoading: isLoadingFollow, + tooltip: isOffline ? offlineActionMessage('follow this account') : null, + ); } Widget _buildMoreButton(BuildContext context) { @@ -129,7 +139,15 @@ ), ]); - return PopupMenuButton(icon: const Icon(Icons.more_vert), itemBuilder: (_) => menuItems); + Widget button = PopupMenuButton( + enabled: !isOffline, + icon: const Icon(Icons.more_vert), + itemBuilder: (_) => menuItems, + ); + if (isOffline) { + button = Tooltip(message: offlineActionMessage('manage this profile'), child: button); + } + return button; } void _confirmUnfollow(BuildContext context) { @@ -258,6 +276,7 @@ this.isSecondary = false, this.foregroundColor, this.backgroundColor, + this.tooltip, }); final String label; @@ -266,20 +285,23 @@ final bool isSecondary; final Color? foregroundColor; final Color? backgroundColor; + final String? tooltip; @override Widget build(BuildContext context) { final theme = Theme.of(context); if (isSecondary) { - return OutlinedButton(onPressed: isLoading ? null : onPressed, child: _buildChild(theme)); + final button = OutlinedButton(onPressed: isLoading ? null : onPressed, child: _buildChild(theme)); + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); } - return FilledButton( + final button = FilledButton( onPressed: isLoading ? null : onPressed, style: FilledButton.styleFrom(foregroundColor: foregroundColor, backgroundColor: backgroundColor), child: _buildChild(theme), ); + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); } Widget _buildChild(ThemeData theme) {