mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: offline usability & caching

+1474 -100
+100 -7
docs/specs/phase-5.md
··· 1 1 --- 2 2 title: Phase 5 Spec 3 - updated: 2026-03-23 3 + updated: 2026-03-25 4 4 --- 5 5 6 6 ## Feature Parity 7 7 8 - - Endpoints to build UI around: 9 - - In search screen: `/xrpc/app.bsky.graph.searchStarterPacks` 10 - - In profile screen: `/xrpc/app.bsky.graph.getSuggestedFollowsByActor`, should be a 11 - sheet accessible via overflow menu 12 - - In settings screen: `/xrpc/app.bsky.video.getUploadLimits` to show remaining daily 13 - video upload limits 8 + Three new endpoint integrations to round out UI coverage. 9 + 10 + --- 11 + 12 + ### 1. Starter Pack Search (Search Screen) 13 + 14 + **Endpoint:** `GET /xrpc/app.bsky.graph.searchStarterPacks` 15 + **Auth:** Not required 16 + 17 + **Request:** 18 + 19 + | Param | Type | Required | Default | Notes | 20 + |----------|--------|----------|---------|-------------------------------| 21 + | `q` | string | yes | — | Lucene-style query | 22 + | `limit` | int | no | 25 | 1–100 | 23 + | `cursor` | string | no | — | Pagination cursor | 24 + 25 + **Response:** 26 + 27 + ```json 28 + { 29 + "cursor": "string?", 30 + "starterPacks": "StarterPackViewBasic[]" 31 + } 32 + ``` 33 + 34 + `StarterPackViewBasic` includes: `uri`, `cid`, `record`, `creator` (ProfileViewBasic), 35 + `listItemCount?`, `joinedWeekCount?`, `joinedAllTimeCount?`, `labels?`, `indexedAt`. 36 + 37 + **SDK:** `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)` 38 + → `XRPCResponse<GraphSearchStarterPacksOutput>` 39 + 40 + **UI:** Add a third "Starter Packs" tab to the search screen alongside Posts and People. 41 + Tapping a result navigates to the existing starter pack detail screen. Infinite scroll 42 + pagination via cursor. Reuse the existing `StarterPackViewBasic` tile pattern from 43 + the profile starter packs tab. 44 + 45 + --- 46 + 47 + ### 2. Suggested Follows (Profile Screen) 48 + 49 + **Endpoint:** `GET /xrpc/app.bsky.graph.getSuggestedFollowsByActor` 50 + **Auth:** Not required 51 + 52 + **Request:** 53 + 54 + | Param | Type | Required | Notes | 55 + |---------|--------|----------|-------------------| 56 + | `actor` | string | yes | DID or handle | 57 + 58 + **Response:** 59 + 60 + ```json 61 + { 62 + "suggestions": "ProfileView[]", 63 + "isFallback": "bool (default false)", 64 + "recIdStr": "string?" 65 + } 66 + ``` 67 + 68 + No pagination — returns all suggestions in one response. 69 + 70 + **SDK:** `bluesky.graph.getSuggestedFollowsByActor(actor:)` 71 + → `XRPCResponse<GraphGetSuggestedFollowsByActorOutput>` 72 + 73 + **UI:** New "Suggested Follows" entry in the profile screen's overflow (more options) 74 + bottom sheet. Opens a draggable scrollable sheet listing `ProfileView` tiles with 75 + follow/unfollow buttons. Each tile navigates to the user's profile on tap. Show empty 76 + state if `suggestions` is empty. Hide the menu entry when viewing own profile. 77 + 78 + --- 79 + 80 + ### 3. Video Upload Limits (Settings Screen) 81 + 82 + **Endpoint:** `GET /xrpc/app.bsky.video.getUploadLimits` 83 + **Auth:** Required 84 + 85 + **Request:** None 86 + 87 + **Response:** 88 + 89 + ```json 90 + { 91 + "canUpload": "bool", 92 + "remainingDailyVideos": "int?", 93 + "remainingDailyBytes": "int?", 94 + "message": "string?", 95 + "error": "string?" 96 + } 97 + ``` 98 + 99 + **SDK:** `bluesky.video.getUploadLimits()` 100 + → `XRPCResponse<VideoGetUploadLimitsOutput>` 101 + 102 + **UI:** New tile in the settings screen's Account section showing daily video upload 103 + quota. Display remaining video count and remaining bytes (formatted as MB/GB). 104 + Show `canUpload` status and any server `message`. Fetch on screen load; show 105 + loading indicator while fetching. If the endpoint returns an error or `canUpload` 106 + is false, show the reason.
+6 -5
docs/tasks/phase-4.md
··· 22 22 ## M15 — Offline Reading & Network Resilience 23 23 24 24 - [x] `ConnectivityCubit` via **connectivity_plus** — expose network state stream 25 - - [ ] Cache last-fetched feed page as serialised JSON in Drift 26 - - [ ] Display cached data immediately on launch, refresh in background 27 - - [ ] "You're offline" banner when connectivity is lost 28 - - [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip 29 - - [ ] Notifications and DM screens show "No connection" empty state when offline with no cache 25 + - [x] Cache last-fetched feed page as serialised JSON in Drift 26 + - [x] Display cached data immediately on launch, refresh in background 27 + - [x] "You're offline" banner when connectivity is lost 28 + - [x] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip 29 + - [x] Notifications and DM screens show "No connection" empty state when offline with no cache 30 + - [x] In Debug/Dev mode, add "Simulate Offline" toggle in settings to test offline UI 30 31 31 32 ## M16 — Jump to Profile 32 33
+72 -1
docs/tasks/phase-5.md
··· 1 1 --- 2 2 title: Phase 5 Task Breakdown 3 - updated: 2026-03-23 3 + updated: 2026-03-25 4 4 --- 5 + 6 + # Phase 5 Milestones 7 + 8 + ## M20 — Starter Pack Search 9 + 10 + ### Core 11 + 12 + - [ ] `SearchRepository.searchStarterPacks()` — call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor 13 + - [ ] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension 14 + 15 + ### Cubit 16 + 17 + - [ ] `SearchBloc` — handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination 18 + - [ ] `SearchState` — add `starterPacks` list and `starterPacksCursor` fields 19 + 20 + ### UI 21 + 22 + - [ ] Search screen UI — add third "Starter Packs" tab pill in `_buildTab` row 23 + - [ ] Starter pack result tile widget — show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab 24 + - [ ] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`) 25 + - [ ] Infinite scroll pagination for starter packs tab 26 + 27 + ### Tests 28 + 29 + - [ ] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination 30 + - [ ] Widget tests: third tab renders, results display, empty state, tap navigation 31 + 32 + ## M21 — Suggested Follows Sheet 33 + 34 + ### Core 35 + 36 + - [ ] `ProfileRepository.getSuggestedFollows()` — call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>` 37 + 38 + ### Cubit 39 + 40 + - [ ] `SuggestedFollowsCubit` — `load(actor:)` fetches suggestions, exposes loaded/loading/error states 41 + 42 + ### UI 43 + 44 + - [ ] Suggested follows sheet widget — `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons 45 + - [ ] Profile screen overflow menu — add "Suggested Follows" `ListTile` entry; hide when viewing own profile 46 + - [ ] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete` 47 + - [ ] Tap profile tile → pop sheet, navigate to profile screen 48 + - [ ] Empty state when no suggestions returned 49 + 50 + ### Tests 51 + 52 + - [ ] Unit tests: repository method, cubit state transitions 53 + - [ ] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state 54 + 55 + ## M22 — Video Upload Limits 56 + 57 + ### Core 58 + 59 + - [ ] `VideoRepository` (or extend settings repository) — `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result 60 + 61 + ### Cubit 62 + 63 + - [ ] `VideoUploadLimitsCubit` — fetch on init, expose `canUpload`, remaining counts, message/error 64 + 65 + ### UI 66 + 67 + - [ ] Settings screen — new tile in Account section: "Video Upload Limits" 68 + - [ ] Tile UI — show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge 69 + - [ ] Loading state while fetching, error state if request fails 70 + - [ ] Display server `message` if present; show `error` text with warning styling if `canUpload` is false 71 + 72 + ### Tests 73 + 74 + - [ ] Unit tests: repository method, cubit state transitions and formatting 75 + - [ ] Widget tests: tile renders limits, loading indicator, error state, message display
+32 -1
lib/core/database/app_database.dart
··· 12 12 CachedPosts, 13 13 Settings, 14 14 SavedFeeds, 15 + CachedFeedPages, 15 16 SearchHistory, 16 17 Drafts, 17 18 SavedPosts, ··· 24 25 static const activeAccountDidSettingKey = 'active_account_did'; 25 26 26 27 @override 27 - int get schemaVersion => 11; 28 + int get schemaVersion => 12; 28 29 29 30 @override 30 31 MigrationStrategy get migration => MigrationStrategy( ··· 70 71 The thread auto-collapse setting is nullable and represented by 71 72 the presence or absence of a row in the existing settings table. 72 73 */ 74 + } 75 + if (from < 12) { 76 + await migrator.createTable(cachedFeedPages); 73 77 } 74 78 }, 75 79 ); ··· 181 185 182 186 Future<int> deleteAllSavedFeeds(String accountDid) => 183 187 (delete(savedFeeds)..where((f) => f.accountDid.equals(accountDid))).go(); 188 + 189 + Future<int> cacheFeedPage({ 190 + required String accountDid, 191 + required String feedKey, 192 + required String payload, 193 + DateTime? fetchedAt, 194 + }) => into(cachedFeedPages).insert( 195 + CachedFeedPagesCompanion( 196 + accountDid: Value(accountDid), 197 + feedKey: Value(feedKey), 198 + payload: Value(payload), 199 + fetchedAt: Value(fetchedAt ?? DateTime.now()), 200 + ), 201 + mode: InsertMode.replace, 202 + ); 203 + 204 + Future<CachedFeedPage?> getCachedFeedPage(String accountDid, String feedKey) { 205 + return (select( 206 + cachedFeedPages, 207 + )..where((entry) => entry.accountDid.equals(accountDid) & entry.feedKey.equals(feedKey))).getSingleOrNull(); 208 + } 209 + 210 + Future<int> deleteCachedFeedPage(String accountDid, String feedKey) { 211 + return (delete( 212 + cachedFeedPages, 213 + )..where((entry) => entry.accountDid.equals(accountDid) & entry.feedKey.equals(feedKey))).go(); 214 + } 184 215 185 216 Future<void> replaceSavedFeeds(String accountDid, List<SavedFeedsCompanion> feeds) async { 186 217 await transaction(() async {
+421
lib/core/database/app_database.g.dart
··· 1825 1825 } 1826 1826 } 1827 1827 1828 + class $CachedFeedPagesTable extends CachedFeedPages with TableInfo<$CachedFeedPagesTable, CachedFeedPage> { 1829 + @override 1830 + final GeneratedDatabase attachedDatabase; 1831 + final String? _alias; 1832 + $CachedFeedPagesTable(this.attachedDatabase, [this._alias]); 1833 + static const VerificationMeta _accountDidMeta = const VerificationMeta('accountDid'); 1834 + @override 1835 + late final GeneratedColumn<String> accountDid = GeneratedColumn<String>( 1836 + 'account_did', 1837 + aliasedName, 1838 + false, 1839 + type: DriftSqlType.string, 1840 + requiredDuringInsert: true, 1841 + ); 1842 + static const VerificationMeta _feedKeyMeta = const VerificationMeta('feedKey'); 1843 + @override 1844 + late final GeneratedColumn<String> feedKey = GeneratedColumn<String>( 1845 + 'feed_key', 1846 + aliasedName, 1847 + false, 1848 + type: DriftSqlType.string, 1849 + requiredDuringInsert: true, 1850 + ); 1851 + static const VerificationMeta _payloadMeta = const VerificationMeta('payload'); 1852 + @override 1853 + late final GeneratedColumn<String> payload = GeneratedColumn<String>( 1854 + 'payload', 1855 + aliasedName, 1856 + false, 1857 + type: DriftSqlType.string, 1858 + requiredDuringInsert: true, 1859 + ); 1860 + static const VerificationMeta _fetchedAtMeta = const VerificationMeta('fetchedAt'); 1861 + @override 1862 + late final GeneratedColumn<DateTime> fetchedAt = GeneratedColumn<DateTime>( 1863 + 'fetched_at', 1864 + aliasedName, 1865 + false, 1866 + type: DriftSqlType.dateTime, 1867 + requiredDuringInsert: false, 1868 + defaultValue: currentDateAndTime, 1869 + ); 1870 + @override 1871 + List<GeneratedColumn> get $columns => [accountDid, feedKey, payload, fetchedAt]; 1872 + @override 1873 + String get aliasedName => _alias ?? actualTableName; 1874 + @override 1875 + String get actualTableName => $name; 1876 + static const String $name = 'cached_feed_pages'; 1877 + @override 1878 + VerificationContext validateIntegrity(Insertable<CachedFeedPage> instance, {bool isInserting = false}) { 1879 + final context = VerificationContext(); 1880 + final data = instance.toColumns(true); 1881 + if (data.containsKey('account_did')) { 1882 + context.handle(_accountDidMeta, accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta)); 1883 + } else if (isInserting) { 1884 + context.missing(_accountDidMeta); 1885 + } 1886 + if (data.containsKey('feed_key')) { 1887 + context.handle(_feedKeyMeta, feedKey.isAcceptableOrUnknown(data['feed_key']!, _feedKeyMeta)); 1888 + } else if (isInserting) { 1889 + context.missing(_feedKeyMeta); 1890 + } 1891 + if (data.containsKey('payload')) { 1892 + context.handle(_payloadMeta, payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); 1893 + } else if (isInserting) { 1894 + context.missing(_payloadMeta); 1895 + } 1896 + if (data.containsKey('fetched_at')) { 1897 + context.handle(_fetchedAtMeta, fetchedAt.isAcceptableOrUnknown(data['fetched_at']!, _fetchedAtMeta)); 1898 + } 1899 + return context; 1900 + } 1901 + 1902 + @override 1903 + Set<GeneratedColumn> get $primaryKey => {accountDid, feedKey}; 1904 + @override 1905 + CachedFeedPage map(Map<String, dynamic> data, {String? tablePrefix}) { 1906 + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; 1907 + return CachedFeedPage( 1908 + accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_did'])!, 1909 + feedKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}feed_key'])!, 1910 + payload: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}payload'])!, 1911 + fetchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}fetched_at'])!, 1912 + ); 1913 + } 1914 + 1915 + @override 1916 + $CachedFeedPagesTable createAlias(String alias) { 1917 + return $CachedFeedPagesTable(attachedDatabase, alias); 1918 + } 1919 + } 1920 + 1921 + class CachedFeedPage extends DataClass implements Insertable<CachedFeedPage> { 1922 + final String accountDid; 1923 + final String feedKey; 1924 + final String payload; 1925 + final DateTime fetchedAt; 1926 + const CachedFeedPage({ 1927 + required this.accountDid, 1928 + required this.feedKey, 1929 + required this.payload, 1930 + required this.fetchedAt, 1931 + }); 1932 + @override 1933 + Map<String, Expression> toColumns(bool nullToAbsent) { 1934 + final map = <String, Expression>{}; 1935 + map['account_did'] = Variable<String>(accountDid); 1936 + map['feed_key'] = Variable<String>(feedKey); 1937 + map['payload'] = Variable<String>(payload); 1938 + map['fetched_at'] = Variable<DateTime>(fetchedAt); 1939 + return map; 1940 + } 1941 + 1942 + CachedFeedPagesCompanion toCompanion(bool nullToAbsent) { 1943 + return CachedFeedPagesCompanion( 1944 + accountDid: Value(accountDid), 1945 + feedKey: Value(feedKey), 1946 + payload: Value(payload), 1947 + fetchedAt: Value(fetchedAt), 1948 + ); 1949 + } 1950 + 1951 + factory CachedFeedPage.fromJson(Map<String, dynamic> json, {ValueSerializer? serializer}) { 1952 + serializer ??= driftRuntimeOptions.defaultSerializer; 1953 + return CachedFeedPage( 1954 + accountDid: serializer.fromJson<String>(json['accountDid']), 1955 + feedKey: serializer.fromJson<String>(json['feedKey']), 1956 + payload: serializer.fromJson<String>(json['payload']), 1957 + fetchedAt: serializer.fromJson<DateTime>(json['fetchedAt']), 1958 + ); 1959 + } 1960 + @override 1961 + Map<String, dynamic> toJson({ValueSerializer? serializer}) { 1962 + serializer ??= driftRuntimeOptions.defaultSerializer; 1963 + return <String, dynamic>{ 1964 + 'accountDid': serializer.toJson<String>(accountDid), 1965 + 'feedKey': serializer.toJson<String>(feedKey), 1966 + 'payload': serializer.toJson<String>(payload), 1967 + 'fetchedAt': serializer.toJson<DateTime>(fetchedAt), 1968 + }; 1969 + } 1970 + 1971 + CachedFeedPage copyWith({String? accountDid, String? feedKey, String? payload, DateTime? fetchedAt}) => 1972 + CachedFeedPage( 1973 + accountDid: accountDid ?? this.accountDid, 1974 + feedKey: feedKey ?? this.feedKey, 1975 + payload: payload ?? this.payload, 1976 + fetchedAt: fetchedAt ?? this.fetchedAt, 1977 + ); 1978 + CachedFeedPage copyWithCompanion(CachedFeedPagesCompanion data) { 1979 + return CachedFeedPage( 1980 + accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, 1981 + feedKey: data.feedKey.present ? data.feedKey.value : this.feedKey, 1982 + payload: data.payload.present ? data.payload.value : this.payload, 1983 + fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt, 1984 + ); 1985 + } 1986 + 1987 + @override 1988 + String toString() { 1989 + return (StringBuffer('CachedFeedPage(') 1990 + ..write('accountDid: $accountDid, ') 1991 + ..write('feedKey: $feedKey, ') 1992 + ..write('payload: $payload, ') 1993 + ..write('fetchedAt: $fetchedAt') 1994 + ..write(')')) 1995 + .toString(); 1996 + } 1997 + 1998 + @override 1999 + int get hashCode => Object.hash(accountDid, feedKey, payload, fetchedAt); 2000 + @override 2001 + bool operator ==(Object other) => 2002 + identical(this, other) || 2003 + (other is CachedFeedPage && 2004 + other.accountDid == this.accountDid && 2005 + other.feedKey == this.feedKey && 2006 + other.payload == this.payload && 2007 + other.fetchedAt == this.fetchedAt); 2008 + } 2009 + 2010 + class CachedFeedPagesCompanion extends UpdateCompanion<CachedFeedPage> { 2011 + final Value<String> accountDid; 2012 + final Value<String> feedKey; 2013 + final Value<String> payload; 2014 + final Value<DateTime> fetchedAt; 2015 + final Value<int> rowid; 2016 + const CachedFeedPagesCompanion({ 2017 + this.accountDid = const Value.absent(), 2018 + this.feedKey = const Value.absent(), 2019 + this.payload = const Value.absent(), 2020 + this.fetchedAt = const Value.absent(), 2021 + this.rowid = const Value.absent(), 2022 + }); 2023 + CachedFeedPagesCompanion.insert({ 2024 + required String accountDid, 2025 + required String feedKey, 2026 + required String payload, 2027 + this.fetchedAt = const Value.absent(), 2028 + this.rowid = const Value.absent(), 2029 + }) : accountDid = Value(accountDid), 2030 + feedKey = Value(feedKey), 2031 + payload = Value(payload); 2032 + static Insertable<CachedFeedPage> custom({ 2033 + Expression<String>? accountDid, 2034 + Expression<String>? feedKey, 2035 + Expression<String>? payload, 2036 + Expression<DateTime>? fetchedAt, 2037 + Expression<int>? rowid, 2038 + }) { 2039 + return RawValuesInsertable({ 2040 + if (accountDid != null) 'account_did': accountDid, 2041 + if (feedKey != null) 'feed_key': feedKey, 2042 + if (payload != null) 'payload': payload, 2043 + if (fetchedAt != null) 'fetched_at': fetchedAt, 2044 + if (rowid != null) 'rowid': rowid, 2045 + }); 2046 + } 2047 + 2048 + CachedFeedPagesCompanion copyWith({ 2049 + Value<String>? accountDid, 2050 + Value<String>? feedKey, 2051 + Value<String>? payload, 2052 + Value<DateTime>? fetchedAt, 2053 + Value<int>? rowid, 2054 + }) { 2055 + return CachedFeedPagesCompanion( 2056 + accountDid: accountDid ?? this.accountDid, 2057 + feedKey: feedKey ?? this.feedKey, 2058 + payload: payload ?? this.payload, 2059 + fetchedAt: fetchedAt ?? this.fetchedAt, 2060 + rowid: rowid ?? this.rowid, 2061 + ); 2062 + } 2063 + 2064 + @override 2065 + Map<String, Expression> toColumns(bool nullToAbsent) { 2066 + final map = <String, Expression>{}; 2067 + if (accountDid.present) { 2068 + map['account_did'] = Variable<String>(accountDid.value); 2069 + } 2070 + if (feedKey.present) { 2071 + map['feed_key'] = Variable<String>(feedKey.value); 2072 + } 2073 + if (payload.present) { 2074 + map['payload'] = Variable<String>(payload.value); 2075 + } 2076 + if (fetchedAt.present) { 2077 + map['fetched_at'] = Variable<DateTime>(fetchedAt.value); 2078 + } 2079 + if (rowid.present) { 2080 + map['rowid'] = Variable<int>(rowid.value); 2081 + } 2082 + return map; 2083 + } 2084 + 2085 + @override 2086 + String toString() { 2087 + return (StringBuffer('CachedFeedPagesCompanion(') 2088 + ..write('accountDid: $accountDid, ') 2089 + ..write('feedKey: $feedKey, ') 2090 + ..write('payload: $payload, ') 2091 + ..write('fetchedAt: $fetchedAt, ') 2092 + ..write('rowid: $rowid') 2093 + ..write(')')) 2094 + .toString(); 2095 + } 2096 + } 2097 + 1828 2098 class $SearchHistoryTable extends SearchHistory with TableInfo<$SearchHistoryTable, SearchHistoryEntry> { 1829 2099 @override 1830 2100 final GeneratedDatabase attachedDatabase; ··· 3275 3545 late final $CachedPostsTable cachedPosts = $CachedPostsTable(this); 3276 3546 late final $SettingsTable settings = $SettingsTable(this); 3277 3547 late final $SavedFeedsTable savedFeeds = $SavedFeedsTable(this); 3548 + late final $CachedFeedPagesTable cachedFeedPages = $CachedFeedPagesTable(this); 3278 3549 late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); 3279 3550 late final $DraftsTable drafts = $DraftsTable(this); 3280 3551 late final $SavedPostsTable savedPosts = $SavedPostsTable(this); ··· 3288 3559 cachedPosts, 3289 3560 settings, 3290 3561 savedFeeds, 3562 + cachedFeedPages, 3291 3563 searchHistory, 3292 3564 drafts, 3293 3565 savedPosts, ··· 4181 4453 SavedFeedEntry, 4182 4454 PrefetchHooks Function() 4183 4455 >; 4456 + typedef $$CachedFeedPagesTableCreateCompanionBuilder = 4457 + CachedFeedPagesCompanion Function({ 4458 + required String accountDid, 4459 + required String feedKey, 4460 + required String payload, 4461 + Value<DateTime> fetchedAt, 4462 + Value<int> rowid, 4463 + }); 4464 + typedef $$CachedFeedPagesTableUpdateCompanionBuilder = 4465 + CachedFeedPagesCompanion Function({ 4466 + Value<String> accountDid, 4467 + Value<String> feedKey, 4468 + Value<String> payload, 4469 + Value<DateTime> fetchedAt, 4470 + Value<int> rowid, 4471 + }); 4472 + 4473 + class $$CachedFeedPagesTableFilterComposer extends Composer<_$AppDatabase, $CachedFeedPagesTable> { 4474 + $$CachedFeedPagesTableFilterComposer({ 4475 + required super.$db, 4476 + required super.$table, 4477 + super.joinBuilder, 4478 + super.$addJoinBuilderToRootComposer, 4479 + super.$removeJoinBuilderFromRootComposer, 4480 + }); 4481 + ColumnFilters<String> get accountDid => 4482 + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnFilters(column)); 4483 + 4484 + ColumnFilters<String> get feedKey => 4485 + $composableBuilder(column: $table.feedKey, builder: (column) => ColumnFilters(column)); 4486 + 4487 + ColumnFilters<String> get payload => 4488 + $composableBuilder(column: $table.payload, builder: (column) => ColumnFilters(column)); 4489 + 4490 + ColumnFilters<DateTime> get fetchedAt => 4491 + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnFilters(column)); 4492 + } 4493 + 4494 + class $$CachedFeedPagesTableOrderingComposer extends Composer<_$AppDatabase, $CachedFeedPagesTable> { 4495 + $$CachedFeedPagesTableOrderingComposer({ 4496 + required super.$db, 4497 + required super.$table, 4498 + super.joinBuilder, 4499 + super.$addJoinBuilderToRootComposer, 4500 + super.$removeJoinBuilderFromRootComposer, 4501 + }); 4502 + ColumnOrderings<String> get accountDid => 4503 + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnOrderings(column)); 4504 + 4505 + ColumnOrderings<String> get feedKey => 4506 + $composableBuilder(column: $table.feedKey, builder: (column) => ColumnOrderings(column)); 4507 + 4508 + ColumnOrderings<String> get payload => 4509 + $composableBuilder(column: $table.payload, builder: (column) => ColumnOrderings(column)); 4510 + 4511 + ColumnOrderings<DateTime> get fetchedAt => 4512 + $composableBuilder(column: $table.fetchedAt, builder: (column) => ColumnOrderings(column)); 4513 + } 4514 + 4515 + class $$CachedFeedPagesTableAnnotationComposer extends Composer<_$AppDatabase, $CachedFeedPagesTable> { 4516 + $$CachedFeedPagesTableAnnotationComposer({ 4517 + required super.$db, 4518 + required super.$table, 4519 + super.joinBuilder, 4520 + super.$addJoinBuilderToRootComposer, 4521 + super.$removeJoinBuilderFromRootComposer, 4522 + }); 4523 + GeneratedColumn<String> get accountDid => $composableBuilder(column: $table.accountDid, builder: (column) => column); 4524 + 4525 + GeneratedColumn<String> get feedKey => $composableBuilder(column: $table.feedKey, builder: (column) => column); 4526 + 4527 + GeneratedColumn<String> get payload => $composableBuilder(column: $table.payload, builder: (column) => column); 4528 + 4529 + GeneratedColumn<DateTime> get fetchedAt => $composableBuilder(column: $table.fetchedAt, builder: (column) => column); 4530 + } 4531 + 4532 + class $$CachedFeedPagesTableTableManager 4533 + extends 4534 + RootTableManager< 4535 + _$AppDatabase, 4536 + $CachedFeedPagesTable, 4537 + CachedFeedPage, 4538 + $$CachedFeedPagesTableFilterComposer, 4539 + $$CachedFeedPagesTableOrderingComposer, 4540 + $$CachedFeedPagesTableAnnotationComposer, 4541 + $$CachedFeedPagesTableCreateCompanionBuilder, 4542 + $$CachedFeedPagesTableUpdateCompanionBuilder, 4543 + (CachedFeedPage, BaseReferences<_$AppDatabase, $CachedFeedPagesTable, CachedFeedPage>), 4544 + CachedFeedPage, 4545 + PrefetchHooks Function() 4546 + > { 4547 + $$CachedFeedPagesTableTableManager(_$AppDatabase db, $CachedFeedPagesTable table) 4548 + : super( 4549 + TableManagerState( 4550 + db: db, 4551 + table: table, 4552 + createFilteringComposer: () => $$CachedFeedPagesTableFilterComposer($db: db, $table: table), 4553 + createOrderingComposer: () => $$CachedFeedPagesTableOrderingComposer($db: db, $table: table), 4554 + createComputedFieldComposer: () => $$CachedFeedPagesTableAnnotationComposer($db: db, $table: table), 4555 + updateCompanionCallback: 4556 + ({ 4557 + Value<String> accountDid = const Value.absent(), 4558 + Value<String> feedKey = const Value.absent(), 4559 + Value<String> payload = const Value.absent(), 4560 + Value<DateTime> fetchedAt = const Value.absent(), 4561 + Value<int> rowid = const Value.absent(), 4562 + }) => CachedFeedPagesCompanion( 4563 + accountDid: accountDid, 4564 + feedKey: feedKey, 4565 + payload: payload, 4566 + fetchedAt: fetchedAt, 4567 + rowid: rowid, 4568 + ), 4569 + createCompanionCallback: 4570 + ({ 4571 + required String accountDid, 4572 + required String feedKey, 4573 + required String payload, 4574 + Value<DateTime> fetchedAt = const Value.absent(), 4575 + Value<int> rowid = const Value.absent(), 4576 + }) => CachedFeedPagesCompanion.insert( 4577 + accountDid: accountDid, 4578 + feedKey: feedKey, 4579 + payload: payload, 4580 + fetchedAt: fetchedAt, 4581 + rowid: rowid, 4582 + ), 4583 + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), 4584 + prefetchHooksCallback: null, 4585 + ), 4586 + ); 4587 + } 4588 + 4589 + typedef $$CachedFeedPagesTableProcessedTableManager = 4590 + ProcessedTableManager< 4591 + _$AppDatabase, 4592 + $CachedFeedPagesTable, 4593 + CachedFeedPage, 4594 + $$CachedFeedPagesTableFilterComposer, 4595 + $$CachedFeedPagesTableOrderingComposer, 4596 + $$CachedFeedPagesTableAnnotationComposer, 4597 + $$CachedFeedPagesTableCreateCompanionBuilder, 4598 + $$CachedFeedPagesTableUpdateCompanionBuilder, 4599 + (CachedFeedPage, BaseReferences<_$AppDatabase, $CachedFeedPagesTable, CachedFeedPage>), 4600 + CachedFeedPage, 4601 + PrefetchHooks Function() 4602 + >; 4184 4603 typedef $$SearchHistoryTableCreateCompanionBuilder = 4185 4604 SearchHistoryCompanion Function({ 4186 4605 Value<int> id, ··· 4896 5315 $$CachedPostsTableTableManager get cachedPosts => $$CachedPostsTableTableManager(_db, _db.cachedPosts); 4897 5316 $$SettingsTableTableManager get settings => $$SettingsTableTableManager(_db, _db.settings); 4898 5317 $$SavedFeedsTableTableManager get savedFeeds => $$SavedFeedsTableTableManager(_db, _db.savedFeeds); 5318 + $$CachedFeedPagesTableTableManager get cachedFeedPages => 5319 + $$CachedFeedPagesTableTableManager(_db, _db.cachedFeedPages); 4899 5320 $$SearchHistoryTableTableManager get searchHistory => $$SearchHistoryTableTableManager(_db, _db.searchHistory); 4900 5321 $$DraftsTableTableManager get drafts => $$DraftsTableTableManager(_db, _db.drafts); 4901 5322 $$SavedPostsTableTableManager get savedPosts => $$SavedPostsTableTableManager(_db, _db.savedPosts);
+11
lib/core/database/tables.dart
··· 67 67 Set<Column> get primaryKey => {id, accountDid}; 68 68 } 69 69 70 + @DataClassName('CachedFeedPage') 71 + class CachedFeedPages extends Table { 72 + TextColumn get accountDid => text()(); 73 + TextColumn get feedKey => text()(); 74 + TextColumn get payload => text()(); 75 + DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); 76 + 77 + @override 78 + Set<Column> get primaryKey => {accountDid, feedKey}; 79 + } 80 + 70 81 @DataClassName('SearchHistoryEntry') 71 82 class SearchHistory extends Table { 72 83 IntColumn get id => integer().autoIncrement()();
+15 -3
lib/core/router/app_shell.dart
··· 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 4 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 5 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 6 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 5 7 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 6 8 import 'package:lazurite/features/profile/data/profile_repository.dart'; 7 9 import 'package:provider/provider.dart'; ··· 141 143 final currentPath = GoRouterState.of(rootContext).uri.path; 142 144 final isMessagesRoute = currentPath.startsWith('/alerts/messages') || currentPath.startsWith('/alerts/requests'); 143 145 final isNotificationsRoute = currentPath.startsWith('/alerts') && !isMessagesRoute; 146 + final isOffline = rootContext.read<ConnectivityCubit>().state.isOffline; 144 147 final tokens = rootContext.watch<AuthBloc>().state.tokens; 145 148 final displayName = tokens?.displayName ?? tokens?.handle ?? 'Guest'; 146 149 final handle = tokens?.handle ?? 'Sign in required'; ··· 260 263 icon: Icons.add_circle_outline, 261 264 selectedIcon: Icons.add_circle, 262 265 label: 'New Post', 263 - onTap: () => _pushRoute(context, '/compose'), 266 + tooltip: isOffline ? offlineActionMessage('compose a post') : null, 267 + onTap: isOffline ? null : () => _pushRoute(context, '/compose'), 264 268 ), 265 269 _MenuTile( 266 270 icon: Icons.settings_outlined, ··· 408 412 this.isSelected = false, 409 413 this.isDestructive = false, 410 414 this.trailing, 415 + this.tooltip, 411 416 }); 412 417 413 418 final IconData icon; 414 419 final IconData selectedIcon; 415 420 final String label; 416 - final VoidCallback onTap; 421 + final VoidCallback? onTap; 417 422 final bool isSelected; 418 423 final bool isDestructive; 419 424 final Widget? trailing; 425 + final String? tooltip; 420 426 421 427 @override 422 428 Widget build(BuildContext context) { ··· 427 433 ? theme.colorScheme.primary 428 434 : theme.colorScheme.onSurface; 429 435 430 - return ListTile( 436 + Widget tile = ListTile( 431 437 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), 432 438 leading: Icon(isSelected ? selectedIcon : icon, color: color), 433 439 title: Text( ··· 439 445 selectedTileColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.45), 440 446 onTap: onTap, 441 447 ); 448 + 449 + if (tooltip != null) { 450 + tile = Tooltip(message: tooltip!, child: tile); 451 + } 452 + 453 + return tile; 442 454 } 443 455 }
+13 -6
lib/features/compose/presentation/compose_screen.dart
··· 7 7 import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:image_picker/image_picker.dart'; 9 9 import 'package:intl/intl.dart'; 10 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 11 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 10 12 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 11 13 12 14 class ComposeScreen extends StatefulWidget { ··· 513 515 TextButton(onPressed: _saveDraft, child: const Text('Save Draft')), 514 516 BlocBuilder<ComposeBloc, ComposeState>( 515 517 builder: (context, state) { 518 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 519 + final button = TextButton( 520 + onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, 521 + child: state.isSubmitting 522 + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 523 + : const Text('Post'), 524 + ); 525 + 516 526 return Padding( 517 527 padding: const EdgeInsets.only(right: 8), 518 - child: TextButton( 519 - onPressed: state.canSubmit && !state.isSubmitting ? _submitPost : null, 520 - child: state.isSubmitting 521 - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 522 - : const Text('Post'), 523 - ), 528 + child: isOffline 529 + ? Tooltip(message: offlineActionMessage('publish your post'), child: button) 530 + : button, 524 531 ); 525 532 }, 526 533 ),
+13
lib/features/connectivity/connectivity_helpers.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 4 + 5 + bool isOffline(BuildContext context) => context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 6 + 7 + String offlineActionMessage(String action) => 'You\'re offline. Reconnect to $action.'; 8 + 9 + void showOfflineSnackBar(BuildContext context, {required String action}) { 10 + ScaffoldMessenger.of(context) 11 + ..hideCurrentSnackBar() 12 + ..showSnackBar(SnackBar(content: Text(offlineActionMessage(action)), behavior: SnackBarBehavior.floating)); 13 + }
+20 -8
lib/features/connectivity/cubit/connectivity_cubit.dart
··· 7 7 part 'connectivity_state.dart'; 8 8 9 9 class ConnectivityCubit extends Cubit<ConnectivityState> { 10 - ConnectivityCubit({Connectivity? connectivity}) 10 + ConnectivityCubit({Connectivity? connectivity, bool simulateOffline = false}) 11 11 : _connectivity = connectivity ?? Connectivity(), 12 - super(const ConnectivityState.online()) { 12 + _simulateOffline = simulateOffline, 13 + super(ConnectivityState(hasNetworkConnection: true, isSimulatedOffline: simulateOffline)) { 13 14 _init(); 14 15 } 15 16 16 17 final Connectivity _connectivity; 17 18 StreamSubscription<List<ConnectivityResult>>? _subscription; 19 + bool _simulateOffline; 20 + bool _hasNetworkConnection = true; 18 21 19 22 void _init() { 20 23 _connectivity.checkConnectivity().then(_handleResults); 21 24 _subscription = _connectivity.onConnectivityChanged.listen(_handleResults); 22 25 } 23 26 24 - void _handleResults(List<ConnectivityResult> results) { 25 - final isOnline = results.any((r) => r != ConnectivityResult.none); 26 - if (isOnline) { 27 - emit(const ConnectivityState.online()); 28 - } else { 29 - emit(const ConnectivityState.offline()); 27 + void setSimulatedOffline(bool value) { 28 + if (_simulateOffline == value) { 29 + return; 30 30 } 31 + 32 + _simulateOffline = value; 33 + _emitCurrentState(); 34 + } 35 + 36 + void _handleResults(List<ConnectivityResult> results) { 37 + _hasNetworkConnection = results.any((result) => result != ConnectivityResult.none); 38 + _emitCurrentState(); 39 + } 40 + 41 + void _emitCurrentState() { 42 + emit(ConnectivityState(hasNetworkConnection: _hasNetworkConnection, isSimulatedOffline: _simulateOffline)); 31 43 } 32 44 33 45 @override
+11 -5
lib/features/connectivity/cubit/connectivity_state.dart
··· 3 3 enum ConnectivityStatus { online, offline } 4 4 5 5 class ConnectivityState extends Equatable { 6 - const ConnectivityState({required this.isOnline}); 6 + const ConnectivityState({required this.hasNetworkConnection, this.isSimulatedOffline = false}); 7 7 8 - const ConnectivityState.online() : this(isOnline: true); 8 + const ConnectivityState.online({bool isSimulatedOffline = false}) 9 + : this(hasNetworkConnection: true, isSimulatedOffline: isSimulatedOffline); 9 10 10 - const ConnectivityState.offline() : this(isOnline: false); 11 + const ConnectivityState.offline({bool hasNetworkConnection = false, bool isSimulatedOffline = false}) 12 + : this(hasNetworkConnection: hasNetworkConnection, isSimulatedOffline: isSimulatedOffline); 11 13 12 - final bool isOnline; 14 + final bool hasNetworkConnection; 15 + final bool isSimulatedOffline; 16 + 17 + bool get isOnline => hasNetworkConnection && !isSimulatedOffline; 18 + bool get isOffline => !isOnline; 13 19 14 20 ConnectivityStatus get status => isOnline ? ConnectivityStatus.online : ConnectivityStatus.offline; 15 21 16 22 @override 17 - List<Object?> get props => [isOnline]; 23 + List<Object?> get props => [hasNetworkConnection, isSimulatedOffline]; 18 24 }
+68
lib/features/connectivity/presentation/connectivity_banner_host.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 4 + 5 + class ConnectivityBannerHost extends StatelessWidget { 6 + const ConnectivityBannerHost({super.key, required this.child}); 7 + 8 + final Widget child; 9 + 10 + @override 11 + Widget build(BuildContext context) { 12 + return BlocBuilder<ConnectivityCubit, ConnectivityState>( 13 + builder: (context, state) { 14 + return Stack( 15 + children: [ 16 + Positioned.fill(child: child), 17 + Positioned( 18 + top: 0, 19 + left: 0, 20 + right: 0, 21 + child: SafeArea( 22 + bottom: false, 23 + child: AnimatedSlide( 24 + duration: const Duration(milliseconds: 200), 25 + offset: state.isOffline ? Offset.zero : const Offset(0, -1), 26 + child: IgnorePointer( 27 + ignoring: true, 28 + child: Padding( 29 + padding: const EdgeInsets.all(12), 30 + child: Material( 31 + color: Colors.transparent, 32 + child: Container( 33 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 34 + decoration: BoxDecoration( 35 + color: Theme.of(context).colorScheme.errorContainer, 36 + borderRadius: BorderRadius.circular(16), 37 + border: Border.all(color: Theme.of(context).colorScheme.error), 38 + ), 39 + child: Row( 40 + children: [ 41 + Icon(Icons.cloud_off, color: Theme.of(context).colorScheme.onErrorContainer, size: 18), 42 + const SizedBox(width: 10), 43 + Expanded( 44 + child: Text( 45 + state.isSimulatedOffline 46 + ? 'You\'re offline (simulated in developer settings).' 47 + : 'You\'re offline.', 48 + style: Theme.of(context).textTheme.bodyMedium?.copyWith( 49 + color: Theme.of(context).colorScheme.onErrorContainer, 50 + fontWeight: FontWeight.w600, 51 + ), 52 + ), 53 + ), 54 + ], 55 + ), 56 + ), 57 + ), 58 + ), 59 + ), 60 + ), 61 + ), 62 + ), 63 + ], 64 + ); 65 + }, 66 + ); 67 + } 68 + }
+65 -5
lib/features/feed/data/feed_repository.dart
··· 1 + import 'dart:convert'; 2 + 1 3 import 'package:atproto_core/atproto_core.dart'; 2 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 6 import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; 5 7 import 'package:bluesky/bluesky.dart'; 8 + import 'package:lazurite/core/database/app_database.dart'; 6 9 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 7 10 8 11 class FeedRepository { 9 - FeedRepository({required Bluesky bluesky, ModerationService? moderationService}) 10 - : _bluesky = bluesky, 11 - _moderationService = moderationService; 12 + FeedRepository({ 13 + required Bluesky bluesky, 14 + required AppDatabase database, 15 + required String accountDid, 16 + ModerationService? moderationService, 17 + }) : _bluesky = bluesky, 18 + _database = database, 19 + _accountDid = accountDid, 20 + _moderationService = moderationService; 12 21 13 22 final Bluesky _bluesky; 23 + final AppDatabase _database; 24 + final String _accountDid; 14 25 final ModerationService? _moderationService; 15 26 27 + static const String timelineCacheKey = 'timeline'; 28 + 29 + static String cacheKeyForSavedFeed(SavedFeed feed) { 30 + final feedType = feed.type; 31 + if (feedType is SavedFeedTypeKnownValue && feedType.data == KnownSavedFeedType.timeline) { 32 + return timelineCacheKey; 33 + } 34 + 35 + return 'feed:${feed.value}'; 36 + } 37 + 16 38 Future<FeedResult> getAuthorFeed({ 17 39 required String actor, 18 40 FeedFilter filter = FeedFilter.postsAndAuthorThreads, ··· 40 62 $headers: await _moderationService?.headersForRequest(), 41 63 ); 42 64 43 - return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 65 + final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 66 + await _cacheFirstPageIfNeeded(feedKey: timelineCacheKey, result: result, cursor: cursor); 67 + return result; 44 68 } 45 69 46 70 Future<FeedResult> getFeed({required AtUri feedUri, String? cursor, int limit = 50}) async { ··· 51 75 $headers: await _moderationService?.headersForRequest(), 52 76 ); 53 77 54 - return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 78 + final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 79 + await _cacheFirstPageIfNeeded(feedKey: 'feed:${feedUri.toString()}', result: result, cursor: cursor); 80 + return result; 81 + } 82 + 83 + Future<FeedResult?> getCachedFeedPage(String feedKey) async { 84 + final cached = await _database.getCachedFeedPage(_accountDid, feedKey); 85 + if (cached == null) { 86 + return null; 87 + } 88 + 89 + final decoded = jsonDecode(cached.payload) as Map<String, dynamic>; 90 + final rawPosts = decoded['posts'] as List<dynamic>? ?? const []; 91 + final posts = rawPosts 92 + .map((entry) => FeedViewPost.fromJson(Map<String, dynamic>.from(entry as Map))) 93 + .toList(growable: false); 94 + 95 + return FeedResult(posts: posts, cursor: decoded['cursor'] as String?); 55 96 } 56 97 57 98 Future<PreferencesResult> getPreferences() async { ··· 97 138 } 98 139 99 140 return posts.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); 141 + } 142 + 143 + Future<void> _cacheFirstPageIfNeeded({ 144 + required String feedKey, 145 + required FeedResult result, 146 + required String? cursor, 147 + }) async { 148 + if (cursor != null) { 149 + return; 150 + } 151 + 152 + await _database.cacheFeedPage( 153 + accountDid: _accountDid, 154 + feedKey: feedKey, 155 + payload: jsonEncode({ 156 + 'cursor': result.cursor, 157 + 'posts': result.posts.map((post) => post.toJson()).toList(growable: false), 158 + }), 159 + ); 100 160 } 101 161 } 102 162
+43 -3
lib/features/feed/presentation/home_feed_screen.dart
··· 6 6 import 'package:go_router/go_router.dart'; 7 7 import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 8 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 10 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 9 11 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 10 12 import 'package:lazurite/features/feed/data/feed_repository.dart'; 11 13 import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; ··· 73 75 } 74 76 75 77 final pinnedFeeds = prefsState.pinnedFeeds; 78 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 76 79 77 80 if (pinnedFeeds.isEmpty) { 78 81 return Scaffold( ··· 134 137 ), 135 138 floatingActionButton: FloatingActionButton( 136 139 heroTag: 'home-compose-fab', 137 - onPressed: () => context.push('/compose'), 140 + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 141 + onPressed: isOffline ? null : () => context.push('/compose'), 138 142 shape: const CircleBorder(), 139 143 child: const Icon(Icons.add), 140 144 ), ··· 240 244 final List<FeedViewPost> _posts = []; 241 245 String? _cursor; 242 246 bool _isLoading = false; 247 + bool _showInitialLoading = false; 243 248 bool _isLoadingMore = false; 244 249 bool _hasError = false; 245 250 String? _errorMessage; ··· 252 257 void initState() { 253 258 super.initState(); 254 259 _scrollController.addListener(_onScroll); 255 - _loadFeed(); 260 + _primeFeed(); 256 261 } 257 262 258 263 @override ··· 269 274 } 270 275 271 276 Future<void> _loadFeed() async { 277 + await _loadFeedInternal(showLoading: _posts.isEmpty); 278 + } 279 + 280 + Future<void> _primeFeed() async { 281 + final cachedResult = await _loadCachedFeed(); 282 + if (cachedResult != null) { 283 + _setStateIfMounted(() { 284 + _posts 285 + ..clear() 286 + ..addAll(cachedResult.posts); 287 + _cursor = cachedResult.cursor; 288 + _hasError = false; 289 + _errorMessage = null; 290 + }); 291 + } 292 + 293 + await _loadFeedInternal(showLoading: cachedResult == null); 294 + } 295 + 296 + Future<void> _loadFeedInternal({required bool showLoading}) async { 272 297 if (_isLoading) return; 273 298 274 299 _setStateIfMounted(() { 275 300 _isLoading = true; 301 + _showInitialLoading = showLoading; 276 302 _hasError = false; 277 303 _errorMessage = null; 278 304 }); ··· 286 312 _posts.addAll(result.posts); 287 313 _cursor = result.cursor; 288 314 _isLoading = false; 315 + _showInitialLoading = false; 289 316 _hasError = false; 290 317 }); 291 318 } catch (e) { 319 + if (_posts.isNotEmpty) { 320 + _setStateIfMounted(() { 321 + _isLoading = false; 322 + _showInitialLoading = false; 323 + }); 324 + return; 325 + } 326 + 292 327 _setStateIfMounted(() { 293 328 _isLoading = false; 329 + _showInitialLoading = false; 294 330 _hasError = true; 295 331 _errorMessage = e.toString(); 296 332 }); 297 333 } 334 + } 335 + 336 + Future<FeedResult?> _loadCachedFeed() { 337 + return context.read<FeedRepository>().getCachedFeedPage(FeedRepository.cacheKeyForSavedFeed(widget.feed)); 298 338 } 299 339 300 340 Future<void> _loadMore() async { ··· 340 380 Widget build(BuildContext context) { 341 381 super.build(context); 342 382 343 - if (_isLoading) { 383 + if (_showInitialLoading) { 344 384 return const Center(child: CircularProgressIndicator()); 345 385 } 346 386
+3
lib/features/feed/presentation/post_thread_screen.dart
··· 10 10 import 'package:go_router/go_router.dart'; 11 11 import 'package:intl/intl.dart'; 12 12 import 'package:lazurite/core/logging/app_logger.dart'; 13 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 13 14 import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 14 15 import 'package:lazurite/features/feed/cubit/post_thread_cubit.dart'; 15 16 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; ··· 755 756 } 756 757 757 758 Widget _buildActionBar(BuildContext context, PostView post) { 759 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 758 760 return BlocBuilder<PostActionCubit, PostActionState>( 759 761 builder: (context, postActionState) { 760 762 return BlocBuilder<SavedPostsCubit, SavedPostsState>( ··· 782 784 unawaited(_onToggleSave(context)); 783 785 }, 784 786 onMore: () => _showMoreOptions(context), 787 + isOffline: isOffline, 785 788 ); 786 789 }, 787 790 );
+16 -4
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter/services.dart'; 3 3 import 'package:lazurite/core/logging/app_logger.dart'; 4 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 4 5 import 'package:share_plus/share_plus.dart'; 5 6 6 7 class PostActionBar extends StatelessWidget { ··· 28 29 this.onMore, 29 30 this.isLoadingLike = false, 30 31 this.isLoadingRepost = false, 32 + this.isOffline = false, 31 33 }); 32 34 33 35 final int replyCount; ··· 52 54 final VoidCallback? onMore; 53 55 final bool isLoadingLike; 54 56 final bool isLoadingRepost; 57 + final bool isOffline; 55 58 56 59 @override 57 60 Widget build(BuildContext context) { ··· 62 65 icon: Icons.chat_bubble_outline, 63 66 activeIcon: Icons.chat_bubble, 64 67 count: replyCount, 65 - onTap: onReply, 68 + onTap: isOffline ? null : onReply, 69 + tooltip: isOffline ? offlineActionMessage('reply to this post') : null, 66 70 color: Theme.of(context).colorScheme.onSurfaceVariant, 67 71 ), 68 72 _ActionButton( ··· 71 75 count: repostCount, 72 76 isActive: isReposted, 73 77 isLoading: isLoadingRepost, 74 - onTap: onRepost, 78 + onTap: isOffline ? null : onRepost, 75 79 activeColor: Colors.green, 76 - onLongPress: onRepost != null ? () => _showRepostOptions(context) : null, 80 + onLongPress: !isOffline && onRepost != null ? () => _showRepostOptions(context) : null, 81 + tooltip: isOffline ? offlineActionMessage('repost this post') : null, 77 82 ), 78 83 _ActionButton( 79 84 icon: Icons.favorite_outline, ··· 81 86 count: likeCount, 82 87 isActive: isLiked, 83 88 isLoading: isLoadingLike, 84 - onTap: onLike, 89 + onTap: isOffline ? null : onLike, 85 90 activeColor: Colors.pink, 91 + tooltip: isOffline ? offlineActionMessage('like this post') : null, 86 92 ), 87 93 _ActionButton( 88 94 icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, ··· 222 228 this.onLongPress, 223 229 this.color, 224 230 this.activeColor, 231 + this.tooltip, 225 232 }); 226 233 227 234 final IconData icon; ··· 233 240 final VoidCallback? onLongPress; 234 241 final Color? color; 235 242 final Color? activeColor; 243 + final String? tooltip; 236 244 237 245 @override 238 246 Widget build(BuildContext context) { ··· 267 275 onLongPress: onLongPress, 268 276 child: AnimatedScale(scale: isActive ? 1.0 : 1.0, duration: const Duration(milliseconds: 100), child: button), 269 277 ); 278 + } 279 + 280 + if (tooltip != null) { 281 + button = Tooltip(message: tooltip!, child: button); 270 282 } 271 283 272 284 return button;
+18 -4
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter/services.dart'; 3 3 import 'package:intl/intl.dart'; 4 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 4 5 5 6 /// Formats a post timestamp as a short, uppercase string. 6 7 String formatPostTime(DateTime time) { ··· 38 39 this.onCloudSave, 39 40 this.onCloudUnsave, 40 41 this.showCounts = false, 42 + this.isOffline = false, 41 43 }); 42 44 43 45 final String timestamp; ··· 59 61 final VoidCallback? onCloudSave; 60 62 final VoidCallback? onCloudUnsave; 61 63 final bool showCounts; 64 + final bool isOffline; 62 65 63 66 @override 64 67 Widget build(BuildContext context) { ··· 80 83 isActive: false, 81 84 isLoading: false, 82 85 count: replyCount, 83 - onTap: onReply, 86 + onTap: isOffline ? null : onReply, 84 87 color: colorScheme.onSurfaceVariant, 85 88 iconSize: iconSize, 86 89 padding: actionPadding, 87 90 showCount: canShowCounts, 91 + tooltip: isOffline ? offlineActionMessage('reply to this post') : null, 88 92 ), 89 93 _FooterAction( 90 94 icon: Icons.repeat, ··· 92 96 isActive: isReposted, 93 97 isLoading: isLoadingRepost, 94 98 count: repostCount, 95 - onTap: onRepost, 99 + onTap: isOffline ? null : onRepost, 96 100 color: colorScheme.onSurfaceVariant, 97 101 activeColor: Colors.green, 98 102 iconSize: iconSize, 99 103 padding: actionPadding, 100 104 showCount: canShowCounts, 105 + tooltip: isOffline ? offlineActionMessage('repost this post') : null, 101 106 ), 102 107 _FooterAction( 103 108 icon: Icons.favorite_outline, ··· 105 110 isActive: isLiked, 106 111 isLoading: isLoadingLike, 107 112 count: likeCount, 108 - onTap: onLike, 113 + onTap: isOffline ? null : onLike, 109 114 color: colorScheme.onSurfaceVariant, 110 115 activeColor: Colors.pink, 111 116 iconSize: iconSize, 112 117 padding: actionPadding, 113 118 showCount: canShowCounts, 119 + tooltip: isOffline ? offlineActionMessage('like this post') : null, 114 120 ), 115 121 _FooterAction( 116 122 icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, ··· 231 237 this.onLongPress, 232 238 this.color, 233 239 this.activeColor, 240 + this.tooltip, 234 241 }); 235 242 236 243 final IconData icon; ··· 245 252 final VoidCallback? onLongPress; 246 253 final Color? color; 247 254 final Color? activeColor; 255 + final String? tooltip; 248 256 249 257 @override 250 258 Widget build(BuildContext context) { 251 259 final defaultColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; 252 260 final iconColor = isActive ? (activeColor ?? defaultColor) : defaultColor; 253 261 254 - return InkWell( 262 + Widget button = InkWell( 255 263 onTap: isLoading ? null : onTap, 256 264 onLongPress: onLongPress, 257 265 borderRadius: BorderRadius.zero, ··· 276 284 ), 277 285 ), 278 286 ); 287 + 288 + if (tooltip != null) { 289 + button = Tooltip(message: tooltip!, child: button); 290 + } 291 + 292 + return button; 279 293 } 280 294 281 295 String _formatCount(int count) {
+3
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 7 7 import 'package:flutter/services.dart'; 8 8 import 'package:flutter_bloc/flutter_bloc.dart'; 9 9 import 'package:go_router/go_router.dart'; 10 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 10 11 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 11 12 import 'package:lazurite/features/feed/cubit/post_action_cubit.dart'; 12 13 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; ··· 139 140 140 141 Widget _buildFooter(BuildContext context) { 141 142 final post = feedViewPost.post; 143 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 142 144 return BlocBuilder<PostActionCubit, PostActionState>( 143 145 builder: (context, postActionState) { 144 146 return BlocBuilder<SavedPostsCubit, SavedPostsState>( ··· 163 165 onCloudSave: () => unawaited(_onCloudSave(context)), 164 166 onCloudUnsave: () => unawaited(_onCloudUnsave(context)), 165 167 showCounts: true, 168 + isOffline: isOffline, 166 169 ); 167 170 }, 168 171 );
+38
lib/features/messages/presentation/widgets/convo_list_pane.dart
··· 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:go_router/go_router.dart'; 5 5 import 'package:lazurite/core/logging/app_logger.dart'; 6 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 6 7 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 7 8 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 8 9 import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; ··· 71 72 Widget build(BuildContext context) { 72 73 return BlocBuilder<ConvoListBloc, ConvoListState>( 73 74 builder: (context, state) { 75 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 74 76 if (state.status == ConvoListStatus.initial || 75 77 (state.status == ConvoListStatus.loading && state.convos.isEmpty)) { 78 + if (isOffline) { 79 + return const _OfflineConvoState(); 80 + } 76 81 return const Center(child: CircularProgressIndicator()); 77 82 } 78 83 79 84 if (state.status == ConvoListStatus.error && state.convos.isEmpty) { 85 + if (isOffline) { 86 + return const _OfflineConvoState(); 87 + } 80 88 return Center( 81 89 child: Column( 82 90 mainAxisAlignment: MainAxisAlignment.center, ··· 97 105 final filtered = _filteredConvos(state.convos, widget.tab); 98 106 99 107 if (filtered.isEmpty) { 108 + if (isOffline) { 109 + return const _OfflineConvoState(); 110 + } 100 111 return RefreshIndicator( 101 112 onRefresh: _onRefresh, 102 113 child: ListView( ··· 155 166 context.push('/alerts/messages/${convo.id}', extra: MessageThreadRouteArgs(title: title)); 156 167 } 157 168 } 169 + 170 + class _OfflineConvoState extends StatelessWidget { 171 + const _OfflineConvoState(); 172 + 173 + @override 174 + Widget build(BuildContext context) { 175 + return Center( 176 + child: Padding( 177 + padding: const EdgeInsets.all(24), 178 + child: Column( 179 + mainAxisSize: MainAxisSize.min, 180 + children: [ 181 + Icon(Icons.cloud_off_outlined, size: 48, color: Theme.of(context).colorScheme.outline), 182 + const SizedBox(height: 12), 183 + Text('No connection', style: Theme.of(context).textTheme.titleMedium), 184 + const SizedBox(height: 8), 185 + Text( 186 + 'Reconnect to load messages.', 187 + textAlign: TextAlign.center, 188 + style: Theme.of(context).textTheme.bodyMedium, 189 + ), 190 + ], 191 + ), 192 + ), 193 + ); 194 + } 195 + }
+38
lib/features/notifications/presentation/widgets/notifications_pane.dart
··· 1 1 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 4 5 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 5 6 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 6 7 import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; ··· 51 52 Widget build(BuildContext context) { 52 53 return BlocBuilder<NotificationBloc, NotificationState>( 53 54 builder: (context, state) { 55 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 54 56 if (state.status == NotificationStatus.initial || 55 57 (state.status == NotificationStatus.loading && state.notifications.isEmpty)) { 58 + if (isOffline) { 59 + return const _OfflineNotificationsState(); 60 + } 56 61 return const Center(child: CircularProgressIndicator()); 57 62 } 58 63 59 64 if (state.status == NotificationStatus.error && state.notifications.isEmpty) { 65 + if (isOffline) { 66 + return const _OfflineNotificationsState(); 67 + } 60 68 return Center( 61 69 child: Column( 62 70 mainAxisAlignment: MainAxisAlignment.center, ··· 75 83 } 76 84 77 85 if (state.notifications.isEmpty) { 86 + if (isOffline) { 87 + return const _OfflineNotificationsState(); 88 + } 78 89 return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); 79 90 } 80 91 ··· 211 222 ]; 212 223 213 224 return '${months[date.month - 1]} ${date.day}'; 225 + } 226 + } 227 + 228 + class _OfflineNotificationsState extends StatelessWidget { 229 + const _OfflineNotificationsState(); 230 + 231 + @override 232 + Widget build(BuildContext context) { 233 + return Center( 234 + child: Padding( 235 + padding: const EdgeInsets.all(24), 236 + child: Column( 237 + mainAxisSize: MainAxisSize.min, 238 + children: [ 239 + Icon(Icons.cloud_off_outlined, size: 48, color: Theme.of(context).colorScheme.outline), 240 + const SizedBox(height: 12), 241 + Text('No connection', style: Theme.of(context).textTheme.titleMedium), 242 + const SizedBox(height: 8), 243 + Text( 244 + 'Reconnect to load notifications.', 245 + textAlign: TextAlign.center, 246 + style: Theme.of(context).textTheme.bodyMedium, 247 + ), 248 + ], 249 + ), 250 + ), 251 + ); 214 252 } 215 253 } 216 254
+9 -1
lib/features/profile/presentation/profile_screen.dart
··· 11 11 import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; 12 12 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 13 13 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 14 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 15 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 14 16 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 15 17 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 16 18 import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; ··· 422 424 423 425 Widget _buildProfileActions(BuildContext context, ProfileViewDetailed profile) { 424 426 final viewer = profile.viewer; 427 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 425 428 426 429 return BlocProvider( 427 430 create: (context) => ProfileActionCubit( ··· 451 454 isLoadingFollow: state.isLoadingFollow, 452 455 isLoadingMute: state.isLoadingMute, 453 456 isLoadingBlock: state.isLoadingBlock, 457 + isOffline: isOffline, 454 458 onFollow: () => context.read<ProfileActionCubit>().toggleFollow(), 455 459 onUnfollow: () => context.read<ProfileActionCubit>().toggleFollow(), 456 460 onMute: () => context.read<ProfileActionCubit>().toggleMute(), ··· 593 597 final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 594 598 final isOwnProfile = profile.did == currentUserDid; 595 599 final initialText = isOwnProfile ? null : '@${profile.handle} '; 600 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 596 601 597 602 return FloatingActionButton( 598 603 heroTag: 'profile-compose-fab', 599 - onPressed: () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), 604 + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 605 + onPressed: isOffline 606 + ? null 607 + : () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), 600 608 child: const Icon(Icons.add), 601 609 ); 602 610 },
+28 -6
lib/features/profile/presentation/widgets/profile_action_buttons.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter/services.dart'; 3 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 3 4 4 5 class ProfileActionButtons extends StatelessWidget { 5 6 const ProfileActionButtons({ ··· 11 12 required this.isLoadingFollow, 12 13 required this.isLoadingMute, 13 14 required this.isLoadingBlock, 15 + this.isOffline = false, 14 16 this.onFollow, 15 17 this.onUnfollow, 16 18 this.onMute, ··· 28 30 final bool isLoadingFollow; 29 31 final bool isLoadingMute; 30 32 final bool isLoadingBlock; 33 + final bool isOffline; 31 34 final VoidCallback? onFollow; 32 35 final VoidCallback? onUnfollow; 33 36 final VoidCallback? onMute; ··· 53 56 if (isBlocked) { 54 57 return _ActionButton( 55 58 label: 'Unblock', 56 - onPressed: onUnblock != null ? () => _confirmUnblock(context) : null, 59 + onPressed: isOffline || onUnblock == null ? null : () => _confirmUnblock(context), 57 60 isLoading: isLoadingBlock, 58 61 foregroundColor: Theme.of(context).colorScheme.onError, 59 62 backgroundColor: Theme.of(context).colorScheme.error, 63 + tooltip: isOffline ? offlineActionMessage('unblock this account') : null, 60 64 ); 61 65 } 62 66 63 67 if (isFollowing) { 64 68 return _ActionButton( 65 69 label: 'Following', 66 - onPressed: onUnfollow != null ? () => _confirmUnfollow(context) : null, 70 + onPressed: isOffline || onUnfollow == null ? null : () => _confirmUnfollow(context), 67 71 isLoading: isLoadingFollow, 68 72 isSecondary: true, 73 + tooltip: isOffline ? offlineActionMessage('change your follow state') : null, 69 74 ); 70 75 } 71 76 72 - return _ActionButton(label: 'Follow', onPressed: onFollow, isLoading: isLoadingFollow); 77 + return _ActionButton( 78 + label: 'Follow', 79 + onPressed: isOffline ? null : onFollow, 80 + isLoading: isLoadingFollow, 81 + tooltip: isOffline ? offlineActionMessage('follow this account') : null, 82 + ); 73 83 } 74 84 75 85 Widget _buildMoreButton(BuildContext context) { ··· 129 139 ), 130 140 ]); 131 141 132 - return PopupMenuButton<void>(icon: const Icon(Icons.more_vert), itemBuilder: (_) => menuItems); 142 + Widget button = PopupMenuButton<void>( 143 + enabled: !isOffline, 144 + icon: const Icon(Icons.more_vert), 145 + itemBuilder: (_) => menuItems, 146 + ); 147 + if (isOffline) { 148 + button = Tooltip(message: offlineActionMessage('manage this profile'), child: button); 149 + } 150 + return button; 133 151 } 134 152 135 153 void _confirmUnfollow(BuildContext context) { ··· 258 276 this.isSecondary = false, 259 277 this.foregroundColor, 260 278 this.backgroundColor, 279 + this.tooltip, 261 280 }); 262 281 263 282 final String label; ··· 266 285 final bool isSecondary; 267 286 final Color? foregroundColor; 268 287 final Color? backgroundColor; 288 + final String? tooltip; 269 289 270 290 @override 271 291 Widget build(BuildContext context) { 272 292 final theme = Theme.of(context); 273 293 274 294 if (isSecondary) { 275 - return OutlinedButton(onPressed: isLoading ? null : onPressed, child: _buildChild(theme)); 295 + final button = OutlinedButton(onPressed: isLoading ? null : onPressed, child: _buildChild(theme)); 296 + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); 276 297 } 277 298 278 - return FilledButton( 299 + final button = FilledButton( 279 300 onPressed: isLoading ? null : onPressed, 280 301 style: FilledButton.styleFrom(foregroundColor: foregroundColor, backgroundColor: backgroundColor), 281 302 child: _buildChild(theme), 282 303 ); 304 + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); 283 305 } 284 306 285 307 Widget _buildChild(ThemeData theme) {
+11 -4
lib/features/search/presentation/search_screen.dart
··· 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:intl/intl.dart'; 9 9 import 'package:lazurite/core/router/app_shell.dart'; 10 + import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 11 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 10 12 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 11 13 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 12 14 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; ··· 904 906 905 907 @override 906 908 Widget build(BuildContext context) { 909 + final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 907 910 if (_isFollowing) { 908 - return OutlinedButton( 909 - onPressed: _toggleFollow, 911 + final button = OutlinedButton( 912 + onPressed: isOffline ? null : _toggleFollow, 910 913 style: OutlinedButton.styleFrom( 911 914 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 912 915 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 913 916 ), 914 917 child: const Text('Following'), 915 918 ); 919 + 920 + return isOffline ? Tooltip(message: offlineActionMessage('change your follow state'), child: button) : button; 916 921 } 917 922 918 - return FilledButton.tonal( 919 - onPressed: _toggleFollow, 923 + final button = FilledButton.tonal( 924 + onPressed: isOffline ? null : _toggleFollow, 920 925 style: FilledButton.styleFrom( 921 926 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 922 927 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 923 928 ), 924 929 child: const Text('Follow'), 925 930 ); 931 + 932 + return isOffline ? Tooltip(message: offlineActionMessage('follow this account'), child: button) : button; 926 933 } 927 934 928 935 void _toggleFollow() {
+10
lib/features/settings/bloc/settings_cubit.dart
··· 13 13 bool? initialUseSystemTheme, 14 14 UiDensity? initialUiDensity, 15 15 FeedArchitecture? initialFeedArchitecture, 16 + bool? initialSimulateOffline, 16 17 int? initialThreadAutoCollapseDepth, 17 18 }) : super( 18 19 SettingsState( ··· 21 22 useSystemTheme: initialUseSystemTheme ?? false, 22 23 uiDensity: initialUiDensity ?? UiDensity.standard, 23 24 feedArchitecture: initialFeedArchitecture ?? FeedArchitecture.grid, 25 + simulateOffline: initialSimulateOffline ?? false, 24 26 threadAutoCollapseDepth: initialThreadAutoCollapseDepth, 25 27 ), 26 28 ); ··· 32 34 static const String _keyUseSystemTheme = 'use_system_theme'; 33 35 static const String _keyUiDensity = 'ui_density'; 34 36 static const String _keyFeedArchitecture = 'feed_architecture'; 37 + static const String _keySimulateOffline = 'simulate_offline'; 35 38 static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; 36 39 37 40 Future<void> loadSettings() async { ··· 40 43 final useSystemStr = await database.getSetting(_keyUseSystemTheme); 41 44 final uiDensityStr = await database.getSetting(_keyUiDensity); 42 45 final feedArchStr = await database.getSetting(_keyFeedArchitecture); 46 + final simulateOfflineStr = await database.getSetting(_keySimulateOffline); 43 47 final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); 44 48 45 49 emit( ··· 49 53 useSystemTheme: useSystemStr == 'true', 50 54 uiDensity: UiDensity.fromString(uiDensityStr), 51 55 feedArchitecture: FeedArchitecture.fromString(feedArchStr), 56 + simulateOffline: simulateOfflineStr == 'true', 52 57 threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 53 58 ), 54 59 ); ··· 83 88 Future<void> setFeedArchitecture(FeedArchitecture architecture) async { 84 89 await database.setSetting(_keyFeedArchitecture, architecture.name); 85 90 emit(state.copyWith(feedArchitecture: architecture)); 91 + } 92 + 93 + Future<void> setSimulateOffline(bool value) async { 94 + await database.setSetting(_keySimulateOffline, value.toString()); 95 + emit(state.copyWith(simulateOffline: value)); 86 96 } 87 97 88 98 Future<void> setThreadAutoCollapseDepth(int? depth) async {
+5
lib/features/settings/bloc/settings_state.dart
··· 14 14 required this.useSystemTheme, 15 15 this.uiDensity = UiDensity.standard, 16 16 this.feedArchitecture = FeedArchitecture.grid, 17 + this.simulateOffline = false, 17 18 this.threadAutoCollapseDepth, 18 19 }); 19 20 ··· 22 23 final bool useSystemTheme; 23 24 final UiDensity uiDensity; 24 25 final FeedArchitecture feedArchitecture; 26 + final bool simulateOffline; 25 27 final int? threadAutoCollapseDepth; 26 28 27 29 ThemeData get themeData { ··· 35 37 bool? useSystemTheme, 36 38 UiDensity? uiDensity, 37 39 FeedArchitecture? feedArchitecture, 40 + bool? simulateOffline, 38 41 Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, 39 42 }) { 40 43 return SettingsState( ··· 43 46 useSystemTheme: useSystemTheme ?? this.useSystemTheme, 44 47 uiDensity: uiDensity ?? this.uiDensity, 45 48 feedArchitecture: feedArchitecture ?? this.feedArchitecture, 49 + simulateOffline: simulateOffline ?? this.simulateOffline, 46 50 threadAutoCollapseDepth: identical(threadAutoCollapseDepth, _threadAutoCollapseDepthUnset) 47 51 ? this.threadAutoCollapseDepth 48 52 : threadAutoCollapseDepth as int?, ··· 56 60 useSystemTheme, 57 61 uiDensity, 58 62 feedArchitecture, 63 + simulateOffline, 59 64 threadAutoCollapseDepth, 60 65 ]; 61 66 }
+30
lib/features/settings/presentation/settings_screen.dart
··· 1 + import 'package:flutter/foundation.dart'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:flutter_bloc/flutter_bloc.dart'; 3 4 import 'package:go_router/go_router.dart'; ··· 98 99 trailing: Switch(value: false, onChanged: (_) {}), 99 100 ), 100 101 const SizedBox(height: 24), 102 + if (!kReleaseMode) ...[ 103 + _buildSectionHeader(context, 'Developer'), 104 + _buildDeveloperSettings(context), 105 + const SizedBox(height: 24), 106 + ], 101 107 _buildSectionHeader(context, 'About'), 102 108 _SettingsTile( 103 109 icon: Icons.code_outlined, ··· 275 281 onChanged: settingsCubit.setThreadAutoCollapseDepth, 276 282 ), 277 283 ], 284 + ), 285 + ); 286 + }, 287 + ); 288 + } 289 + 290 + Widget _buildDeveloperSettings(BuildContext context) { 291 + final settingsCubit = context.read<SettingsCubit>(); 292 + 293 + return BlocBuilder<SettingsCubit, SettingsState>( 294 + builder: (context, state) { 295 + return Container( 296 + decoration: BoxDecoration( 297 + border: Border( 298 + top: BorderSide(color: Theme.of(context).dividerColor), 299 + bottom: BorderSide(color: Theme.of(context).dividerColor), 300 + ), 301 + color: Theme.of(context).cardColor, 302 + ), 303 + child: _SettingsTile( 304 + icon: Icons.cloud_off_outlined, 305 + title: 'Simulate Offline', 306 + subtitle: 'Force offline UI for testing network resilience', 307 + trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), 278 308 ), 279 309 ); 280 310 },
+21 -2
lib/main.dart
··· 16 16 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 17 17 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 18 18 import 'package:lazurite/features/auth/data/auth_repository.dart'; 19 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 20 + import 'package:lazurite/features/connectivity/presentation/connectivity_banner_host.dart'; 19 21 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 20 22 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 21 23 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; ··· 57 59 58 60 final settingsCubit = SettingsCubit(database: database); 59 61 await settingsCubit.loadSettings(); 62 + final connectivityCubit = ConnectivityCubit(simulateOffline: settingsCubit.state.simulateOffline); 60 63 61 64 final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); 62 65 await accountSwitcherCubit.loadAccounts(); ··· 68 71 authBloc: authBloc, 69 72 database: database, 70 73 settingsCubit: settingsCubit, 74 + connectivityCubit: connectivityCubit, 71 75 accountSwitcherCubit: accountSwitcherCubit, 72 76 ), 73 77 ); ··· 79 83 required this.authBloc, 80 84 required this.database, 81 85 required this.settingsCubit, 86 + required this.connectivityCubit, 82 87 required this.accountSwitcherCubit, 83 88 }); 84 89 85 90 final AuthBloc authBloc; 86 91 final AppDatabase database; 87 92 final SettingsCubit settingsCubit; 93 + final ConnectivityCubit connectivityCubit; 88 94 final AccountSwitcherCubit accountSwitcherCubit; 89 95 90 96 @override ··· 96 102 late GoRouter _router; 97 103 late String _routerSessionKey; 98 104 late final StreamSubscription<String> _authSubscription; 105 + late final StreamSubscription<bool> _simulateOfflineSubscription; 99 106 100 107 @override 101 108 void initState() { ··· 103 110 _routerSessionKey = _sessionKeyFor(widget.authBloc.state); 104 111 _router = _createRouter(); 105 112 _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); 113 + _simulateOfflineSubscription = widget.settingsCubit.stream 114 + .map((state) => state.simulateOffline) 115 + .distinct() 116 + .listen(widget.connectivityCubit.setSimulatedOffline); 106 117 } 107 118 108 119 @override 109 120 void dispose() { 110 121 _authSubscription.cancel(); 122 + _simulateOfflineSubscription.cancel(); 123 + widget.connectivityCubit.close(); 111 124 _router.dispose(); 112 125 super.dispose(); 113 126 } ··· 147 160 providers: [ 148 161 BlocProvider.value(value: widget.authBloc), 149 162 BlocProvider.value(value: widget.settingsCubit), 163 + BlocProvider.value(value: widget.connectivityCubit), 150 164 BlocProvider.value(value: widget.accountSwitcherCubit), 151 165 ], 152 166 child: BlocBuilder<AuthBloc, AuthState>( ··· 170 184 darkTheme: darkTheme, 171 185 themeMode: themeMode, 172 186 routerConfig: _router, 187 + builder: (context, child) => ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), 173 188 ); 174 189 }, 175 190 ); ··· 198 213 dispose: (moderationService) => moderationService.dispose(), 199 214 ), 200 215 RepositoryProvider( 201 - create: (context) => 202 - FeedRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 216 + create: (context) => FeedRepository( 217 + bluesky: bluesky, 218 + database: widget.database, 219 + accountDid: accountDid, 220 + moderationService: context.read<ModerationService>(), 221 + ), 203 222 ), 204 223 RepositoryProvider( 205 224 create: (context) =>
+12
test/core/database/app_database_test.dart
··· 190 190 final cached = await database.select(database.cachedPosts).getSingle(); 191 191 expect(cached.authorDid, equals('did:plc:abc123')); 192 192 }); 193 + 194 + test('should cache a feed page payload', () async { 195 + await database.cacheFeedPage( 196 + accountDid: 'did:plc:test', 197 + feedKey: 'timeline', 198 + payload: '{"cursor":"next","posts":[]}', 199 + ); 200 + 201 + final cached = await database.getCachedFeedPage('did:plc:test', 'timeline'); 202 + expect(cached, isNotNull); 203 + expect(cached!.payload, equals('{"cursor":"next","posts":[]}')); 204 + }); 193 205 }); 194 206 195 207 group('Settings operations', () {
+13
test/core/router/app_router_test.dart
··· 9 9 import 'package:lazurite/core/theme/app_theme.dart'; 10 10 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 11 11 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 12 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 12 13 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 13 14 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 14 15 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; ··· 30 31 31 32 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 32 33 34 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 35 + 33 36 class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} 34 37 35 38 class MockUnreadCountCubit extends MockCubit<UnreadCountState> implements UnreadCountCubit {} ··· 44 47 late MockProfileBloc profileBloc; 45 48 late MockFeedBloc feedBloc; 46 49 late MockSettingsCubit settingsCubit; 50 + late MockConnectivityCubit connectivityCubit; 47 51 late MockAccountSwitcherCubit accountSwitcherCubit; 48 52 late MockUnreadCountCubit unreadCountCubit; 49 53 late MockConvoListBloc convoListBloc; ··· 75 79 profileBloc = MockProfileBloc(); 76 80 feedBloc = MockFeedBloc(); 77 81 settingsCubit = MockSettingsCubit(); 82 + connectivityCubit = MockConnectivityCubit(); 78 83 accountSwitcherCubit = MockAccountSwitcherCubit(); 79 84 unreadCountCubit = MockUnreadCountCubit(); 80 85 convoListBloc = MockConvoListBloc(); ··· 95 100 useSystemTheme: false, 96 101 ), 97 102 ); 103 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 98 104 when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 99 105 when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); 100 106 when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); ··· 127 133 ), 128 134 ); 129 135 whenListen( 136 + connectivityCubit, 137 + const Stream<ConnectivityState>.empty(), 138 + initialState: const ConnectivityState.online(), 139 + ); 140 + whenListen( 130 141 accountSwitcherCubit, 131 142 const Stream<AccountSwitcherState>.empty(), 132 143 initialState: const AccountSwitcherState.ready(accounts: []), ··· 150 161 BlocProvider<ProfileBloc>.value(value: profileBloc), 151 162 BlocProvider<FeedBloc>.value(value: feedBloc), 152 163 BlocProvider<SettingsCubit>.value(value: settingsCubit), 164 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 153 165 BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 154 166 BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit), 155 167 BlocProvider<ConvoListBloc>.value(value: convoListBloc), ··· 276 288 BlocProvider<ProfileBloc>.value(value: profileBloc), 277 289 BlocProvider<FeedBloc>.value(value: feedBloc), 278 290 BlocProvider<SettingsCubit>.value(value: settingsCubit), 291 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 279 292 BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 280 293 ], 281 294 child: BlocBuilder<AuthBloc, AuthState>(
+15
test/features/alerts/presentation/alerts_screen_test.dart
··· 3 3 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 4 import 'package:bluesky/chat_bsky_actor_defs.dart' as chat_actor; 5 5 import 'package:bluesky/chat_bsky_convo_defs.dart'; 6 + import 'package:bloc_test/bloc_test.dart'; 6 7 import 'package:flutter/material.dart'; 7 8 import 'package:flutter_bloc/flutter_bloc.dart'; 8 9 import 'package:flutter_test/flutter_test.dart'; 9 10 import 'package:go_router/go_router.dart'; 10 11 import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 12 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 11 13 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 12 14 import 'package:lazurite/features/messages/data/convo_repository.dart'; 13 15 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; ··· 19 21 20 22 class MockConvoRepository extends Mock implements ConvoRepository {} 21 23 24 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 25 + 22 26 void main() { 23 27 late MockNotificationRepository notificationRepository; 24 28 late MockConvoRepository convoRepository; 29 + late MockConnectivityCubit connectivityCubit; 25 30 26 31 setUp(() { 27 32 notificationRepository = MockNotificationRepository(); 28 33 convoRepository = MockConvoRepository(); 34 + connectivityCubit = MockConnectivityCubit(); 35 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 36 + whenListen( 37 + connectivityCubit, 38 + const Stream<ConnectivityState>.empty(), 39 + initialState: const ConnectivityState.online(), 40 + ); 29 41 30 42 when( 31 43 () => notificationRepository.listNotifications( ··· 121 133 BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), 122 134 BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), 123 135 BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), 136 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 124 137 RepositoryProvider<String>.value(value: 'did:plc:me'), 125 138 ], 126 139 child: const AlertsScreen(), ··· 133 146 BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), 134 147 BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), 135 148 BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), 149 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 136 150 RepositoryProvider<String>.value(value: 'did:plc:me'), 137 151 ], 138 152 child: const AlertsScreen(initialTab: AlertsTab.messages), ··· 145 159 BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), 146 160 BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), 147 161 BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), 162 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 148 163 RepositoryProvider<String>.value(value: 'did:plc:me'), 149 164 ], 150 165 child: const AlertsScreen(initialTab: AlertsTab.requests),
+26 -4
test/features/compose/presentation/compose_screen_test.dart
··· 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:flutter_test/flutter_test.dart'; 5 5 import 'package:lazurite/core/database/app_database.dart'; 6 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 6 7 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 7 8 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; 8 9 import 'package:mocktail/mocktail.dart'; 9 10 10 11 class MockComposeBloc extends MockBloc<ComposeEvent, ComposeState> implements ComposeBloc {} 12 + 13 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 11 14 12 15 class FakeDraftsCompanion extends Fake implements DraftsCompanion {} 13 16 ··· 28 31 29 32 void main() { 30 33 late MockComposeBloc mockBloc; 34 + late MockConnectivityCubit connectivityCubit; 31 35 32 36 setUp(() { 33 37 registerFallbackValue(FakeDraftsCompanion()); 34 38 registerFallbackValue(const TextChanged('')); 35 39 mockBloc = MockComposeBloc(); 40 + connectivityCubit = MockConnectivityCubit(); 41 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 42 + whenListen( 43 + connectivityCubit, 44 + const Stream<ConnectivityState>.empty(), 45 + initialState: const ConnectivityState.online(), 46 + ); 36 47 }); 37 48 38 - tearDown(() => mockBloc.close()); 49 + tearDown(() { 50 + mockBloc.close(); 51 + }); 39 52 40 53 Widget buildSubject() => MaterialApp( 41 - home: BlocProvider<ComposeBloc>.value(value: mockBloc, child: const ComposeScreen()), 54 + home: MultiBlocProvider( 55 + providers: [ 56 + BlocProvider<ComposeBloc>.value(value: mockBloc), 57 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 58 + ], 59 + child: const ComposeScreen(), 60 + ), 42 61 ); 43 62 44 63 void seedState(ComposeState state) { ··· 79 98 80 99 await tester.pumpWidget( 81 100 MaterialApp( 82 - home: BlocProvider<ComposeBloc>.value( 83 - value: mockBloc, 101 + home: MultiBlocProvider( 102 + providers: [ 103 + BlocProvider<ComposeBloc>.value(value: mockBloc), 104 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 105 + ], 84 106 child: const ComposeScreen(initialText: '@river.bsky.social '), 85 107 ), 86 108 ),
+13
test/features/connectivity/cubit/connectivity_cubit_test.dart
··· 74 74 expect(state.isOnline, isFalse); 75 75 expect(state.status, ConnectivityStatus.offline); 76 76 }); 77 + 78 + blocTest<ConnectivityCubit, ConnectivityState>( 79 + 'setSimulatedOffline forces offline until cleared', 80 + build: () => ConnectivityCubit(connectivity: mockConnectivity), 81 + act: (cubit) { 82 + cubit.setSimulatedOffline(true); 83 + cubit.setSimulatedOffline(false); 84 + }, 85 + expect: () => [ 86 + predicate<ConnectivityState>((state) => state.isOffline && state.isSimulatedOffline), 87 + predicate<ConnectivityState>((state) => state.isOnline && !state.isSimulatedOffline), 88 + ], 89 + ); 77 90 }); 78 91 }
+20 -1
test/features/feed/presentation/home_feed_screen_test.dart
··· 8 8 import 'package:lazurite/core/theme/app_theme.dart'; 9 9 import 'package:lazurite/core/theme/feed_architecture.dart'; 10 10 import 'package:lazurite/core/theme/ui_density.dart'; 11 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 11 12 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 12 13 import 'package:lazurite/features/feed/data/feed_repository.dart'; 13 14 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; ··· 21 22 class MockFeedPreferencesCubit extends MockCubit<FeedPreferencesState> implements FeedPreferencesCubit {} 22 23 23 24 class MockFeedRepository extends Mock implements FeedRepository {} 25 + 26 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 24 27 25 28 SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( 26 29 themePalette: AppThemePalette.oxocarbon, ··· 70 73 required FeedPreferencesCubit feedPreferencesCubit, 71 74 required FeedRepository feedRepository, 72 75 }) { 76 + final connectivityCubit = MockConnectivityCubit(); 77 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 78 + whenListen( 79 + connectivityCubit, 80 + const Stream<ConnectivityState>.empty(), 81 + initialState: const ConnectivityState.online(), 82 + ); 83 + 73 84 return MaterialApp( 74 85 home: RepositoryProvider<FeedRepository>.value( 75 86 value: feedRepository, 76 - child: BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit, child: const HomeFeedScreen()), 87 + child: MultiBlocProvider( 88 + providers: [ 89 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 90 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 91 + ], 92 + child: const HomeFeedScreen(), 93 + ), 77 94 ), 78 95 ); 79 96 } ··· 299 316 300 317 when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); 301 318 whenListen(feedPreferencesCubit, const Stream<FeedPreferencesState>.empty(), initialState: _homeFeedState); 319 + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); 302 320 when( 303 321 () => feedRepository.getTimeline( 304 322 cursor: any(named: 'cursor'), ··· 327 345 328 346 when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); 329 347 whenListen(feedPreferencesCubit, const Stream<FeedPreferencesState>.empty(), initialState: _homeFeedState); 348 + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); 330 349 when( 331 350 () => feedRepository.getTimeline( 332 351 cursor: any(named: 'cursor'),
+26
test/features/feed/presentation/post_action_bar_test.dart
··· 18 18 VoidCallback? onRepost, 19 19 VoidCallback? onLike, 20 20 VoidCallback? onReply, 21 + bool isOffline = false, 21 22 }) { 22 23 return MaterialApp( 23 24 home: Scaffold( ··· 38 39 onRepost: onRepost, 39 40 onLike: onLike, 40 41 onReply: onReply, 42 + isOffline: isOffline, 41 43 ), 42 44 ), 43 45 ); ··· 148 150 await tester.pumpAndSettle(); 149 151 150 152 expect(cloudUnsaveCalled, isTrue); 153 + }); 154 + 155 + testWidgets('offline mode disables reply, repost, and like actions', (tester) async { 156 + var replyCalled = false; 157 + var repostCalled = false; 158 + var likeCalled = false; 159 + 160 + await tester.pumpWidget( 161 + _buildBar( 162 + isOffline: true, 163 + onReply: () => replyCalled = true, 164 + onRepost: () => repostCalled = true, 165 + onLike: () => likeCalled = true, 166 + ), 167 + ); 168 + 169 + await tester.tap(find.byIcon(Icons.chat_bubble_outline)); 170 + await tester.tap(find.byIcon(Icons.repeat)); 171 + await tester.tap(find.byIcon(Icons.favorite_outline)); 172 + await tester.pump(); 173 + 174 + expect(replyCalled, isFalse); 175 + expect(repostCalled, isFalse); 176 + expect(likeCalled, isFalse); 151 177 }); 152 178 }); 153 179 }
+39 -18
test/features/feed/presentation/post_thread_screen_test.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 8 9 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 9 10 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 10 11 import 'package:lazurite/features/feed/data/post_action_repository.dart'; ··· 14 15 class MockPostActionRepository extends Mock implements PostActionRepository {} 15 16 16 17 class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 18 + 19 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 17 20 18 21 PostView _makePost({ 19 22 required String did, ··· 52 55 required this.thread, 53 56 required this.savedPostsCubit, 54 57 required this.postActionRepository, 58 + required this.connectivityCubit, 55 59 this.initialCollapsedUris = const <String>{}, 56 60 this.onContinueThread, 57 61 }); ··· 59 63 final ThreadViewPost thread; 60 64 final SavedPostsCubit savedPostsCubit; 61 65 final PostActionRepository postActionRepository; 66 + final ConnectivityCubit connectivityCubit; 62 67 final Set<String> initialCollapsedUris; 63 68 final ValueChanged<ThreadViewPost>? onContinueThread; 64 69 ··· 85 90 ], 86 91 child: BlocProvider<SavedPostsCubit>.value( 87 92 value: widget.savedPostsCubit, 88 - child: Scaffold( 89 - body: SingleChildScrollView( 90 - child: ThreadReplyNode( 91 - thread: widget.thread, 92 - depth: 1, 93 - accountDid: 'did:plc:current', 94 - opDid: 'did:plc:op', 95 - collapsedUris: collapsedUris, 96 - onToggleCollapse: (postUri) { 97 - setState(() { 98 - if (collapsedUris.contains(postUri)) { 99 - collapsedUris.remove(postUri); 100 - } else { 101 - collapsedUris.add(postUri); 102 - } 103 - }); 104 - }, 105 - onContinueThread: widget.onContinueThread, 93 + child: BlocProvider<ConnectivityCubit>.value( 94 + value: widget.connectivityCubit, 95 + child: Scaffold( 96 + body: SingleChildScrollView( 97 + child: ThreadReplyNode( 98 + thread: widget.thread, 99 + depth: 1, 100 + accountDid: 'did:plc:current', 101 + opDid: 'did:plc:op', 102 + collapsedUris: collapsedUris, 103 + onToggleCollapse: (postUri) { 104 + setState(() { 105 + if (collapsedUris.contains(postUri)) { 106 + collapsedUris.remove(postUri); 107 + } else { 108 + collapsedUris.add(postUri); 109 + } 110 + }); 111 + }, 112 + onContinueThread: widget.onContinueThread, 113 + ), 106 114 ), 107 115 ), 108 116 ), ··· 115 123 void main() { 116 124 late MockPostActionRepository mockPostActionRepository; 117 125 late MockSavedPostsCubit mockSavedPostsCubit; 126 + late MockConnectivityCubit mockConnectivityCubit; 118 127 119 128 setUp(() { 120 129 mockPostActionRepository = MockPostActionRepository(); 121 130 mockSavedPostsCubit = MockSavedPostsCubit(); 131 + mockConnectivityCubit = MockConnectivityCubit(); 122 132 123 133 const savedState = SavedPostsState(status: SavedPostsStatus.loaded, savedPosts: [], savedUris: {}); 124 134 when(() => mockSavedPostsCubit.state).thenReturn(savedState); 125 135 whenListen(mockSavedPostsCubit, const Stream<SavedPostsState>.empty(), initialState: savedState); 136 + when(() => mockConnectivityCubit.state).thenReturn(const ConnectivityState.online()); 137 + whenListen( 138 + mockConnectivityCubit, 139 + const Stream<ConnectivityState>.empty(), 140 + initialState: const ConnectivityState.online(), 141 + ); 126 142 }); 127 143 128 144 testWidgets('renders nested threaded replies recursively', (tester) async { ··· 152 168 thread: parent, 153 169 savedPostsCubit: mockSavedPostsCubit, 154 170 postActionRepository: mockPostActionRepository, 171 + connectivityCubit: mockConnectivityCubit, 155 172 ), 156 173 ); 157 174 await tester.pumpAndSettle(); ··· 190 207 thread: parent, 191 208 savedPostsCubit: mockSavedPostsCubit, 192 209 postActionRepository: mockPostActionRepository, 210 + connectivityCubit: mockConnectivityCubit, 193 211 ), 194 212 ); 195 213 await tester.pumpAndSettle(); ··· 227 245 thread: parent, 228 246 savedPostsCubit: mockSavedPostsCubit, 229 247 postActionRepository: mockPostActionRepository, 248 + connectivityCubit: mockConnectivityCubit, 230 249 ), 231 250 ); 232 251 await tester.pumpAndSettle(); ··· 278 297 thread: depth1, 279 298 savedPostsCubit: mockSavedPostsCubit, 280 299 postActionRepository: mockPostActionRepository, 300 + connectivityCubit: mockConnectivityCubit, 281 301 onContinueThread: (thread) => continuedThread = thread, 282 302 ), 283 303 ); ··· 397 417 thread: depth1, 398 418 savedPostsCubit: mockSavedPostsCubit, 399 419 postActionRepository: mockPostActionRepository, 420 + connectivityCubit: mockConnectivityCubit, 400 421 initialCollapsedUris: computeInitialCollapsedThreadUris(root, autoCollapseDepth: 2), 401 422 ), 402 423 );
+16 -1
test/features/feed/presentation/saved_posts_screen_test.dart
··· 4 4 import 'package:atproto_core/atproto_core.dart'; 5 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:bloc_test/bloc_test.dart'; 7 8 import 'package:flutter/material.dart'; 8 9 import 'package:flutter_bloc/flutter_bloc.dart'; 9 10 import 'package:flutter_test/flutter_test.dart'; 10 11 import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 11 12 import 'package:lazurite/core/database/app_database.dart'; 13 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 12 14 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 13 15 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 14 16 import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; ··· 18 20 class MockAppDatabase extends Mock implements AppDatabase {} 19 21 20 22 class MockPostActionRepository extends Mock implements PostActionRepository {} 23 + 24 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 21 25 22 26 PostView _makePostView({ 23 27 String did = 'did:plc:author', ··· 52 56 void main() { 53 57 late MockAppDatabase mockDatabase; 54 58 late MockPostActionRepository mockPostActionRepository; 59 + late MockConnectivityCubit connectivityCubit; 55 60 56 61 const testAccountDid = 'did:plc:me'; 57 62 58 63 setUp(() { 59 64 mockDatabase = MockAppDatabase(); 60 65 mockPostActionRepository = MockPostActionRepository(); 66 + connectivityCubit = MockConnectivityCubit(); 67 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 68 + whenListen( 69 + connectivityCubit, 70 + const Stream<ConnectivityState>.empty(), 71 + initialState: const ConnectivityState.online(), 72 + ); 61 73 62 74 when(() => mockDatabase.watchSavedPostsWithType(testAccountDid)).thenAnswer((_) => Stream.value({})); 63 75 when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); ··· 77 89 RepositoryProvider<PostActionCache>(create: (_) => PostActionCache()), 78 90 RepositoryProvider<String>.value(value: testAccountDid), 79 91 ], 80 - child: const MaterialApp(home: SavedPostsScreen(accountDid: testAccountDid)), 92 + child: BlocProvider<ConnectivityCubit>.value( 93 + value: connectivityCubit, 94 + child: const MaterialApp(home: SavedPostsScreen(accountDid: testAccountDid)), 95 + ), 81 96 ); 82 97 } 83 98
+34 -1
test/features/messages/presentation/convo_list_screen_test.dart
··· 1 1 import 'package:bluesky/chat_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 + import 'package:bloc_test/bloc_test.dart'; 3 4 import 'package:flutter/material.dart'; 4 5 import 'package:flutter_bloc/flutter_bloc.dart'; 5 6 import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 6 8 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 7 9 import 'package:lazurite/features/messages/data/convo_repository.dart'; 8 10 import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; 9 11 import 'package:mocktail/mocktail.dart'; 10 12 11 13 class MockConvoRepository extends Mock implements ConvoRepository {} 14 + 15 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 12 16 13 17 void main() { 14 18 const currentUserDid = 'did:plc:me'; 15 19 16 20 late MockConvoRepository mockRepository; 21 + late MockConnectivityCubit connectivityCubit; 17 22 18 23 setUp(() { 19 24 mockRepository = MockConvoRepository(); 25 + connectivityCubit = MockConnectivityCubit(); 26 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 27 + whenListen( 28 + connectivityCubit, 29 + const Stream<ConnectivityState>.empty(), 30 + initialState: const ConnectivityState.online(), 31 + ); 20 32 }); 21 33 22 34 ProfileViewBasic makeProfile({String did = 'did:plc:other', String handle = 'other.bsky.social'}) => ··· 39 51 child: MaterialApp( 40 52 home: BlocProvider( 41 53 create: (_) => ConvoListBloc(convoRepository: mockRepository), 42 - child: const ConvoListScreen(), 54 + child: BlocProvider<ConnectivityCubit>.value(value: connectivityCubit, child: const ConvoListScreen()), 43 55 ), 44 56 ), 45 57 ); ··· 102 114 await tester.pumpAndSettle(); 103 115 104 116 expect(find.text('No conversations yet'), findsOneWidget); 117 + }); 118 + 119 + testWidgets('shows offline empty state when offline with no conversations', (tester) async { 120 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.offline()); 121 + whenListen( 122 + connectivityCubit, 123 + const Stream<ConnectivityState>.empty(), 124 + initialState: const ConnectivityState.offline(), 125 + ); 126 + when( 127 + () => mockRepository.listConvos( 128 + cursor: any(named: 'cursor'), 129 + limit: any(named: 'limit'), 130 + ), 131 + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); 132 + 133 + await tester.pumpWidget(buildSubject()); 134 + await tester.pumpAndSettle(); 135 + 136 + expect(find.text('No connection'), findsOneWidget); 137 + expect(find.text('Reconnect to load messages.'), findsOneWidget); 105 138 }); 106 139 107 140 testWidgets('shows error state on failure', (tester) async {
+28
test/features/notifications/presentation/notifications_screen_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 3 import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:bloc_test/bloc_test.dart'; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter_bloc/flutter_bloc.dart'; 6 7 import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 7 9 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 8 10 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 9 11 import 'package:lazurite/features/notifications/data/notification_repository.dart'; ··· 13 15 import 'package:mocktail/mocktail.dart'; 14 16 15 17 class MockNotificationRepository extends Mock implements NotificationRepository {} 18 + 19 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 16 20 17 21 void main() { 18 22 group('NotificationsScreen', () { 19 23 late MockNotificationRepository mockNotificationRepository; 24 + late MockConnectivityCubit connectivityCubit; 20 25 21 26 setUp(() { 22 27 mockNotificationRepository = MockNotificationRepository(); 28 + connectivityCubit = MockConnectivityCubit(); 29 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 30 + whenListen( 31 + connectivityCubit, 32 + const Stream<ConnectivityState>.empty(), 33 + initialState: const ConnectivityState.online(), 34 + ); 23 35 when( 24 36 () => mockNotificationRepository.listNotifications( 25 37 cursor: any(named: 'cursor'), ··· 40 52 BlocProvider<UnreadCountCubit>( 41 53 create: (_) => UnreadCountCubit(notificationRepository: mockNotificationRepository), 42 54 ), 55 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 43 56 ], 44 57 child: const NotificationsScreen(), 45 58 ), ··· 88 101 await tester.pumpAndSettle(); 89 102 90 103 expect(find.text('No notifications yet'), findsOneWidget); 104 + }); 105 + 106 + testWidgets('displays offline empty state when offline with no notifications', (tester) async { 107 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.offline()); 108 + whenListen( 109 + connectivityCubit, 110 + const Stream<ConnectivityState>.empty(), 111 + initialState: const ConnectivityState.offline(), 112 + ); 113 + 114 + await tester.pumpWidget(buildSubject()); 115 + await tester.pumpAndSettle(); 116 + 117 + expect(find.text('No connection'), findsOneWidget); 118 + expect(find.text('Reconnect to load notifications.'), findsOneWidget); 91 119 }); 92 120 93 121 testWidgets('displays error state on failure', (tester) async {
+17
test/features/profile/presentation/profile_screen_test.dart
··· 15 15 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 16 16 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 17 17 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 18 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 18 19 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 19 20 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 20 21 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; ··· 37 38 38 39 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 39 40 41 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 42 + 40 43 class MockPostActionRepository extends Mock implements PostActionRepository {} 41 44 42 45 class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} ··· 50 53 late MockProfileBloc profileBloc; 51 54 late MockFeedBloc feedBloc; 52 55 late MockSettingsCubit settingsCubit; 56 + late MockConnectivityCubit connectivityCubit; 53 57 54 58 const tokens = AuthTokens( 55 59 accessToken: 'access', ··· 92 96 profileBloc = MockProfileBloc(); 93 97 feedBloc = MockFeedBloc(); 94 98 settingsCubit = MockSettingsCubit(); 99 + connectivityCubit = MockConnectivityCubit(); 95 100 96 101 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 97 102 when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); ··· 99 104 const FeedState.loaded(actor: 'did:plc:me', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 100 105 ); 101 106 when(() => settingsCubit.state).thenReturn(defaultSettingsState()); 107 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 102 108 103 109 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 104 110 whenListen(profileBloc, const Stream<ProfileState>.empty(), initialState: ProfileState.loaded(profile: profile)); ··· 113 119 ), 114 120 ); 115 121 whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: defaultSettingsState()); 122 + whenListen( 123 + connectivityCubit, 124 + const Stream<ConnectivityState>.empty(), 125 + initialState: const ConnectivityState.online(), 126 + ); 116 127 }); 117 128 118 129 Widget buildSubject() { ··· 122 133 BlocProvider<ProfileBloc>.value(value: profileBloc), 123 134 BlocProvider<FeedBloc>.value(value: feedBloc), 124 135 BlocProvider<SettingsCubit>.value(value: settingsCubit), 136 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 125 137 ], 126 138 child: const MaterialApp(home: ProfileScreen()), 127 139 ); ··· 190 202 BlocProvider<ProfileBloc>.value(value: profileBloc), 191 203 BlocProvider<FeedBloc>.value(value: feedBloc), 192 204 BlocProvider<SettingsCubit>.value(value: settingsCubit), 205 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 193 206 ], 194 207 child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), 195 208 ), ··· 245 258 BlocProvider<AuthBloc>.value(value: authBloc), 246 259 BlocProvider<ProfileBloc>.value(value: profileBloc), 247 260 BlocProvider<FeedBloc>.value(value: feedBloc), 261 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 248 262 BlocProvider<SettingsCubit>.value(value: settingsCubit), 249 263 ], 250 264 child: const ProfileScreen(actor: 'did:plc:other', showBackButton: true), ··· 405 419 BlocProvider<AuthBloc>.value(value: authBloc), 406 420 BlocProvider<ProfileBloc>.value(value: profileBloc), 407 421 BlocProvider<FeedBloc>.value(value: feedBloc), 422 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 408 423 BlocProvider<SettingsCubit>.value(value: settCubit), 409 424 BlocProvider<SavedPostsCubit>.value(value: mockSavedPostsCubit), 410 425 ], ··· 497 512 BlocProvider<AuthBloc>.value(value: authBloc), 498 513 BlocProvider<ProfileBloc>.value(value: profileBloc), 499 514 BlocProvider<FeedBloc>.value(value: feedBloc), 515 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 500 516 BlocProvider<SettingsCubit>.value(value: settingsCubit), 501 517 ], 502 518 child: MultiRepositoryProvider( ··· 538 554 BlocProvider<AuthBloc>.value(value: authBloc), 539 555 BlocProvider<ProfileBloc>.value(value: profileBloc), 540 556 BlocProvider<FeedBloc>.value(value: feedBloc), 557 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 541 558 BlocProvider<SettingsCubit>.value(value: settingsCubit), 542 559 ], 543 560 child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)),
+46 -10
test/features/search/presentation/search_screen_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bloc_test/bloc_test.dart'; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter_bloc/flutter_bloc.dart'; 6 7 import 'package:flutter_test/flutter_test.dart'; 7 8 import 'package:go_router/go_router.dart'; 8 9 import 'package:lazurite/core/database/app_database.dart'; 10 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 9 11 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 10 12 import 'package:lazurite/features/search/data/search_repository.dart'; 11 13 import 'package:lazurite/features/search/presentation/search_screen.dart'; ··· 14 16 class MockSearchRepository extends Mock implements SearchRepository {} 15 17 16 18 class MockAppDatabase extends Mock implements AppDatabase {} 19 + 20 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 17 21 18 22 void main() { 19 23 group('SearchScreen', () { 20 24 late MockSearchRepository mockSearchRepository; 21 25 late MockAppDatabase mockDatabase; 26 + late MockConnectivityCubit connectivityCubit; 22 27 23 28 setUp(() { 24 29 mockSearchRepository = MockSearchRepository(); 25 30 mockDatabase = MockAppDatabase(); 31 + connectivityCubit = MockConnectivityCubit(); 32 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 33 + whenListen( 34 + connectivityCubit, 35 + const Stream<ConnectivityState>.empty(), 36 + initialState: const ConnectivityState.online(), 37 + ); 26 38 when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); 27 39 when( 28 40 () => mockSearchRepository.searchPosts( ··· 49 61 50 62 Widget buildSubject() { 51 63 return MaterialApp( 52 - home: BlocProvider<SearchBloc>( 53 - create: (_) => 54 - SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), 64 + home: MultiBlocProvider( 65 + providers: [ 66 + BlocProvider<SearchBloc>( 67 + create: (_) => SearchBloc( 68 + searchRepository: mockSearchRepository, 69 + database: mockDatabase, 70 + accountDid: 'did:plc:test', 71 + ), 72 + ), 73 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 74 + ], 55 75 child: const SearchScreen(), 56 76 ), 57 77 ); ··· 68 88 database: mockDatabase, 69 89 accountDid: 'did:plc:test', 70 90 ), 71 - child: const SearchScreen(), 91 + child: BlocProvider<ConnectivityCubit>.value(value: connectivityCubit, child: const SearchScreen()), 72 92 ), 73 93 ), 74 94 GoRoute( ··· 159 179 160 180 await tester.pumpWidget( 161 181 MaterialApp( 162 - home: BlocProvider<SearchBloc>( 163 - create: (_) => 164 - SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), 182 + home: MultiBlocProvider( 183 + providers: [ 184 + BlocProvider<SearchBloc>( 185 + create: (_) => SearchBloc( 186 + searchRepository: mockSearchRepository, 187 + database: mockDatabase, 188 + accountDid: 'did:plc:test', 189 + ), 190 + ), 191 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 192 + ], 165 193 child: const SearchScreen(), 166 194 ), 167 195 ), ··· 194 222 195 223 await tester.pumpWidget( 196 224 MaterialApp( 197 - home: BlocProvider<SearchBloc>( 198 - create: (_) => 199 - SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), 225 + home: MultiBlocProvider( 226 + providers: [ 227 + BlocProvider<SearchBloc>( 228 + create: (_) => SearchBloc( 229 + searchRepository: mockSearchRepository, 230 + database: mockDatabase, 231 + accountDid: 'did:plc:test', 232 + ), 233 + ), 234 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 235 + ], 200 236 child: const SearchScreen(), 201 237 ), 202 238 ),
+17
test/features/settings/bloc/settings_cubit_test.dart
··· 27 27 expect(cubit.state.useSystemTheme, false); 28 28 expect(cubit.state.uiDensity, UiDensity.standard); 29 29 expect(cubit.state.feedArchitecture, FeedArchitecture.grid); 30 + expect(cubit.state.simulateOffline, false); 30 31 expect(cubit.state.threadAutoCollapseDepth, isNull); 31 32 }); 32 33 ··· 38 39 initialUseSystemTheme: true, 39 40 initialUiDensity: UiDensity.compact, 40 41 initialFeedArchitecture: FeedArchitecture.linear, 42 + initialSimulateOffline: true, 41 43 initialThreadAutoCollapseDepth: 3, 42 44 ); 43 45 expect(cubit.state.themePalette, AppThemePalette.catppuccin); ··· 45 47 expect(cubit.state.useSystemTheme, true); 46 48 expect(cubit.state.uiDensity, UiDensity.compact); 47 49 expect(cubit.state.feedArchitecture, FeedArchitecture.linear); 50 + expect(cubit.state.simulateOffline, true); 48 51 expect(cubit.state.threadAutoCollapseDepth, 3); 49 52 }); 50 53 ··· 57 60 await database.setSetting('use_system_theme', 'true'); 58 61 await database.setSetting('ui_density', 'compact'); 59 62 await database.setSetting('feed_architecture', 'linear'); 63 + await database.setSetting('simulate_offline', 'true'); 60 64 await database.setSetting('thread_auto_collapse_depth', '4'); 61 65 }, 62 66 act: (cubit) => cubit.loadSettings(), ··· 67 71 .having((s) => s.useSystemTheme, 'useSystemTheme', true) 68 72 .having((s) => s.uiDensity, 'uiDensity', UiDensity.compact) 69 73 .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear) 74 + .having((s) => s.simulateOffline, 'simulateOffline', true) 70 75 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), 71 76 ], 72 77 ); ··· 82 87 .having((s) => s.useSystemTheme, 'useSystemTheme', false) 83 88 .having((s) => s.uiDensity, 'uiDensity', UiDensity.standard) 84 89 .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid) 90 + .having((s) => s.simulateOffline, 'simulateOffline', false) 85 91 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), 86 92 ], 87 93 ); ··· 177 183 verify: (cubit) async { 178 184 final value = await database.getSetting('feed_architecture'); 179 185 expect(value, 'grid'); 186 + }, 187 + ); 188 + 189 + blocTest<SettingsCubit, SettingsState>( 190 + 'setSimulateOffline updates state and persists to database', 191 + build: () => SettingsCubit(database: database), 192 + act: (cubit) => cubit.setSimulateOffline(true), 193 + expect: () => [isA<SettingsState>().having((s) => s.simulateOffline, 'simulateOffline', true)], 194 + verify: (cubit) async { 195 + final value = await database.getSetting('simulate_offline'); 196 + expect(value, 'true'); 180 197 }, 181 198 ); 182 199
+32
test/features/settings/bloc/settings_state_test.dart
··· 100 100 expect(state1, isNot(equals(state2))); 101 101 }); 102 102 103 + test('inequality when simulateOffline differs', () { 104 + const state1 = SettingsState( 105 + themePalette: AppThemePalette.oxocarbon, 106 + themeVariant: AppThemeVariant.dark, 107 + useSystemTheme: false, 108 + simulateOffline: false, 109 + ); 110 + const state2 = SettingsState( 111 + themePalette: AppThemePalette.oxocarbon, 112 + themeVariant: AppThemeVariant.dark, 113 + useSystemTheme: false, 114 + simulateOffline: true, 115 + ); 116 + 117 + expect(state1, isNot(equals(state2))); 118 + }); 119 + 103 120 test('inequality when threadAutoCollapseDepth differs', () { 104 121 const state1 = SettingsState( 105 122 themePalette: AppThemePalette.oxocarbon, ··· 130 147 useSystemTheme: true, 131 148 uiDensity: UiDensity.compact, 132 149 feedArchitecture: FeedArchitecture.linear, 150 + simulateOffline: true, 133 151 threadAutoCollapseDepth: 3, 134 152 ); 135 153 ··· 138 156 expect(updated.useSystemTheme, true); 139 157 expect(updated.uiDensity, UiDensity.compact); 140 158 expect(updated.feedArchitecture, FeedArchitecture.linear); 159 + expect(updated.simulateOffline, true); 141 160 expect(updated.threadAutoCollapseDepth, 3); 142 161 expect(original.themePalette, AppThemePalette.oxocarbon); 143 162 }); ··· 149 168 useSystemTheme: true, 150 169 uiDensity: UiDensity.relaxed, 151 170 feedArchitecture: FeedArchitecture.linear, 171 + simulateOffline: true, 152 172 threadAutoCollapseDepth: 4, 153 173 ); 154 174 ··· 159 179 expect(updated.useSystemTheme, true); 160 180 expect(updated.uiDensity, UiDensity.relaxed); 161 181 expect(updated.feedArchitecture, FeedArchitecture.linear); 182 + expect(updated.simulateOffline, true); 162 183 expect(updated.threadAutoCollapseDepth, 4); 163 184 }); 164 185 ··· 182 203 useSystemTheme: true, 183 204 uiDensity: UiDensity.compact, 184 205 feedArchitecture: FeedArchitecture.linear, 206 + simulateOffline: true, 185 207 threadAutoCollapseDepth: 6, 186 208 ); 187 209 ··· 190 212 expect(state.props, contains(true)); 191 213 expect(state.props, contains(UiDensity.compact)); 192 214 expect(state.props, contains(FeedArchitecture.linear)); 215 + expect(state.props, contains(true)); 193 216 expect(state.props, contains(6)); 194 217 }); 195 218 ··· 209 232 useSystemTheme: false, 210 233 ); 211 234 expect(state.feedArchitecture, FeedArchitecture.grid); 235 + }); 236 + 237 + test('defaults simulateOffline to false', () { 238 + const state = SettingsState( 239 + themePalette: AppThemePalette.oxocarbon, 240 + themeVariant: AppThemeVariant.dark, 241 + useSystemTheme: false, 242 + ); 243 + expect(state.simulateOffline, isFalse); 212 244 }); 213 245 214 246 test('defaults threadAutoCollapseDepth to null', () {