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: UI density and feed settings with persistence

+735 -7
+198
docs/specs/ui-refactor.md
··· 1 + # UI Refactor 2 + 3 + Refactor the app's visual layer toward a sharp, architectural aesthetic with 4 + square geometry and editorial density. Colors and typography stay as-is — this 5 + spec covers card structure, layout geometry, navigation chrome, and a new 6 + user-facing layout settings screen. 7 + 8 + ## Top App Bar 9 + 10 + The current `AppBar` is stock Material. Replace with a custom header: 11 + 12 + - Fixed, full-width, `h-64` (logical pixels), `backdrop-blur` background with 13 + `surfaceContainerLowest` at ~80% opacity 14 + - **Left**: hamburger menu icon + section label (uppercase, `letterSpacing: 3`, 15 + `labelSmall`, `onSurfaceVariant`) 16 + - **Right**: user avatar thumbnail (`32×32`, square, `surfaceContainerHigh` 17 + background, `outlineVariant` border) 18 + - The branding wordmark ("BLUESKY") sits center-right in the home view; on 19 + other screens it is omitted 20 + - Home screen variant adds inline feed switcher tabs (Feed / Discover / Lists) 21 + as uppercase label links in the right cluster 22 + 23 + ## Bottom Navigation Bar 24 + 25 + Current: 6-tab `NavigationBar`, icons only, `height: 50`, 26 + `surfaceContainerHighest` background, `RoundedSuperellipseBorder` indicator. 27 + 28 + Target: 4-tab bar — Home, Search, Notifications (labeled "Alerts"), Profile. 29 + 30 + | Change | Detail | 31 + | ------------ | ----------------------------------------------------------------------------------------- | 32 + | Tab count | 6 → 4. Messages and Settings move behind the hamburger menu drawer | 33 + | Height | `50` → `80` (includes safe-area padding) | 34 + | Background | Semi-transparent (`surface` at 80% opacity) + backdrop blur | 35 + | Indicator | Drop `RoundedSuperellipseBorder` indicator; active state is filled icon + slight scale-up | 36 + | Labels | `alwaysHide` → show labels beneath icons (uppercase, `10px`, `letterSpacing: 0.1em`) | 37 + | Unread badge | Keep existing badge on Notifications | 38 + 39 + ## Navigation Drawer 40 + 41 + New. Triggered by the hamburger icon in the top app bar. 42 + 43 + Contents (top-to-bottom): 44 + 45 + - Messages 46 + - Settings 47 + - (extensible — future items like Saved Posts, Lists, Feeds management) 48 + 49 + Use `Drawer` with the same backdrop-blur surface treatment as the nav bar. 50 + 51 + ## Post Card — Linear (List) Layout 52 + 53 + Current card uses `Card` with `elevation: 0`, `RoundedRectangleBorder`, 54 + `vertical margin: 1`. Keep this as the "Linear Flow" variant. 55 + 56 + Changes: 57 + 58 + - Replace `Card` wrapper with a `Container` using `border: Border.all(outlineVariant)` and `surfaceContainerLowest` fill 59 + - Remove `CircleAvatar` — replace with `5×5` square avatar container 60 + (`surfaceContainerHighest` background, `outlineVariant` border) 61 + - Author handle: uppercase, `letterSpacing: widest`, `labelSmall`, bold 62 + - Timestamp: right-aligned in the action bar row, uppercase, `10px`, 63 + `onSurfaceVariant` 64 + - Body text: `bodySmall`, `line-clamp: 2` (via `maxLines: 2, overflow: ellipsis`) 65 + - Action bar: move inside a top-bordered footer area 66 + (`border-t outlineVariant`). Icons only (chat, repeat, favorite) in a left-aligned 67 + row. Timestamp right-aligned in the same row 68 + - Embed images: keep existing grid logic, but use square aspect ratio in grid view 69 + 70 + ## Post Card — Grid Layout 71 + 72 + New card variant for the "Grid Matrix" feed architecture. 73 + 74 + Structure (top-to-bottom): 75 + 76 + 1. **Image region** — square (`aspectRatio: 1`), `surfaceContainerHigh` 77 + background, `BoxFit.cover`, grayscale filter by default (colorize on 78 + hover/press is optional) 79 + 2. **Content region** (padding `16`): 80 + - Author row: `5×5` square avatar + handle (same style as linear) 81 + - Body text: `bodySmall`, `maxLines: 2`, ellipsis 82 + - Footer: top-bordered, icons left, relative timestamp right 83 + 84 + Text-only variant (no image): content region expands to fill the card with 85 + larger body text (`titleMedium`, `tracking: tight`). Secondary text below in 86 + `labelSmall`. 87 + 88 + Outer container: `surfaceContainerLowest`, `border: outlineVariant`, 89 + `hover:border: primary` (interaction feedback). 90 + 91 + ## Profile Screen 92 + 93 + Current: `NestedScrollView` with collapsible header, `CircleAvatar`, 94 + `TabBar` (Posts / Replies / Media). 95 + 96 + Refactor to an asymmetric "bento" layout: 97 + 98 + ### Header 99 + 100 + - Cover image: `h-192` to `h-256` (responsive), grayscale, `opacity: 0.5`, 101 + `surfaceContainerHigh` fallback, `outlineVariant` bottom border 102 + - Avatar: `96×96` to `128×128` square (not circle), `surfaceContainerLowest` 103 + background, `4px` background-color border 104 + - Display name: `headlineLarge`, semibold, `tracking: tight`, uppercase 105 + - Handle: `labelMedium`, `onSurfaceVariant` 106 + - Bio: `bodyMedium`, max-width `~500px` 107 + - Stats row: inside a `border-y outlineVariant` container. 108 + Each stat: value (`titleMedium`, bold) above label (uppercase, `11px`, 109 + `letterSpacing: 0.1em`, `onSurfaceVariant`) 110 + - Edit Profile / Follow button: uppercase, `letterSpacing: widest`, `labelSmall`, 111 + bold, `primary` fill with `onPrimary` text 112 + 113 + ### Tabs 114 + 115 + - Sticky below top app bar 116 + - Backdrop-blur background 117 + - Tab labels: uppercase, `11px`, `letterSpacing: 0.2em`, bold 118 + - Active indicator: `2px` bottom border in `primary` 119 + 120 + ### Content Area 121 + 122 + Profile posts use a `12-column` asymmetric bento grid: 123 + 124 + - Pinned post spans `8 columns` (featured, with full image embed) 125 + - Metadata / info card spans `4 columns` (`surfaceContainerHigh` background) 126 + - Remaining posts in `6+6` two-column pairs 127 + 128 + The bento grid applies when the feed architecture is set to "Grid Matrix". 129 + When set to "Linear Flow", profile posts render as a standard vertical list 130 + using the linear post card. 131 + 132 + ## Feed Architecture — Home Screen Grid 133 + 134 + When the user selects "Grid Matrix" layout, the home feed renders in a 135 + responsive grid: 136 + 137 + | Breakpoint | Columns | 138 + | -------------------------- | ------- | 139 + | `< 600px` (phone portrait) | 1 | 140 + | `600–839px` | 2 | 141 + | `840–1199px` | 3 | 142 + | `≥ 1200px` | 4 | 143 + 144 + Use `SliverGrid` with `SliverGridDelegateWithFixedCrossAxisCount`. Cards are 145 + the grid post card variant described above. 146 + 147 + When set to "Linear Flow", keep the current `ListView` of linear post cards. 148 + 149 + ## Layout Settings Screen 150 + 151 + New settings section (accessible from the Settings screen or the drawer). 152 + 153 + ### UI Density 154 + 155 + Three radio-style cards: 156 + 157 + | Option | Description | 158 + | ---------------------- | ------------------------------------------------ | 159 + | **Compact** | Maximum information density. Minimal whitespace. | 160 + | **Standard** (default) | Balanced proportions. | 161 + | **Relaxed** | Expansive margins. Focus-oriented layout. | 162 + 163 + Each option renders as a selectable card with a schematic icon (horizontal 164 + bars of varying spacing), title, subtitle, and a square checkbox indicator 165 + (filled = selected, outlined = deselected). 166 + 167 + Density values map to padding/margin scale factors applied globally via an 168 + `InheritedWidget` or theme extension. 169 + 170 + ### Feed Architecture 171 + 172 + Two square toggle cards: 173 + 174 + | Option | Description | 175 + | ------------------------- | ------------------ | 176 + | **Grid Matrix** (default) | 2×2 schematic icon | 177 + | **Linear Flow** | 3-row stacked icon | 178 + 179 + Selected state: `2px primary` border. Unselected: `1px outlineVariant` border, 180 + `hover:primary`. 181 + 182 + ### Viewport Preview 183 + 184 + Sticky sidebar (or bottom section on narrow screens) showing a schematic 185 + wireframe preview of the selected layout configuration. Updates live as the 186 + user toggles density and feed architecture options. 187 + 188 + ### Persistence 189 + 190 + Store `ui_density` (`compact` | `standard` | `relaxed`) and 191 + `feed_architecture` (`grid` | `linear`) in the Drift `settings` table. Expose 192 + via `SettingsCubit` alongside existing theme preferences. 193 + 194 + ## Shared Geometry Tokens 195 + 196 + All `0px` border-radius throughout (square corners). Ensure no Flutter widgets 197 + use rounded corners except where explicitly noted (e.g., circular unread 198 + badges).
+55
docs/tasks/ui-refactor.md
··· 1 + # UI Refactor Milestones 2 + 3 + ## M0 — Foundation & Layout Settings Persistence 4 + 5 + - [ ] Add `ui_density` and `feed_architecture` keys to Drift `settings` table 6 + - [ ] Drift migration for new settings keys 7 + - [ ] Extend `SettingsCubit` / `SettingsState` with density and feed architecture fields 8 + - [ ] `UiDensity` enum (`compact`, `standard`, `relaxed`) with padding scale factors 9 + - [ ] `FeedArchitecture` enum (`grid`, `linear`) 10 + - [ ] Theme extension or `InheritedWidget` that provides density-scaled spacing values 11 + 12 + ## M1 — Navigation Chrome 13 + 14 + - [ ] Custom top app bar widget replacing stock `AppBar` — hamburger, section label, avatar 15 + - [ ] Home-screen variant with inline feed switcher tabs 16 + - [ ] Navigation drawer with Messages and Settings entries 17 + - [ ] Refactor `AppShell` bottom nav: 6 tabs → 4 (Home, Search, Alerts, Profile) 18 + - [ ] Bottom nav styling: `h-80`, semi-transparent blur background, labels, filled active icon 19 + - [ ] Route updates — Messages and Settings accessible via drawer instead of bottom tabs 20 + - [ ] Tests for navigation (drawer opens, tabs switch, routes resolve) 21 + 22 + ## M2 — Post Card Variants 23 + 24 + - [ ] Refactor `PostCard` to the linear variant: square avatars, uppercase handle, bordered footer 25 + - [ ] New `GridPostCard` widget — image region, content region, footer 26 + - [ ] Text-only grid card variant (no image — expanded body text) 27 + - [ ] Shared `PostCardFooter` widget (action icons left, timestamp right, top border) 28 + - [ ] Wire both variants to `PostCardWithActions` for action state management 29 + - [ ] Tests for both card variants (golden or widget tests) 30 + 31 + ## M3 — Home Feed Grid Layout 32 + 33 + - [ ] `HomeFeedScreen` reads `feed_architecture` from `SettingsCubit` 34 + - [ ] Grid mode: responsive `SliverGrid` with breakpoint-based column count 35 + - [ ] Linear mode: existing `ListView` of linear post cards (no change) 36 + - [ ] Feed architecture toggle triggers rebuild without re-fetch 37 + - [ ] Tests for grid/linear switching and column count at breakpoints 38 + 39 + ## M4 — Profile Screen Refactor 40 + 41 + - [ ] Profile header: square avatar, cover image (grayscale, opacity), stats row with border 42 + - [ ] Display name uppercase + tight tracking, handle below 43 + - [ ] Sticky tab bar with backdrop blur and uppercase labels 44 + - [ ] Bento grid layout for profile posts (8+4 featured row, 6+6 pairs) in grid mode 45 + - [ ] Linear fallback for profile posts when feed architecture is "linear" 46 + - [ ] Tests for profile header rendering and layout mode switching 47 + 48 + ## M5 — Layout Settings Screen 49 + 50 + - [ ] UI Density selector — three radio-style cards with schematic icons 51 + - [ ] Feed Architecture selector — two square toggle cards 52 + - [ ] Viewport Preview wireframe that updates live with selections 53 + - [ ] Settings screen entry point (new section or drawer link) 54 + - [ ] Persist selections to Drift on change 55 + - [ ] Tests for settings screen interactions and persistence round-trip
+6 -1
lib/core/database/app_database.dart
··· 23 23 AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); 24 24 25 25 @override 26 - int get schemaVersion => 9; 26 + int get schemaVersion => 10; 27 27 28 28 @override 29 29 MigrationStrategy get migration => MigrationStrategy( ··· 58 58 } 59 59 if (from < 9) { 60 60 await migrator.createTable(labelerCache); 61 + } 62 + if (from < 10) { 63 + await customStatement( 64 + "INSERT OR IGNORE INTO settings (key, value) VALUES ('ui_density', 'standard'), ('feed_architecture', 'grid')", 65 + ); 61 66 } 62 67 }, 63 68 );
+37
lib/core/theme/density_spacing.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:lazurite/core/theme/ui_density.dart'; 3 + 4 + /// ThemeExtension providing density-scaled spacing values. 5 + /// 6 + /// Obtain via `Theme.of(context).extension<DensitySpacing>()`. 7 + class DensitySpacing extends ThemeExtension<DensitySpacing> { 8 + const DensitySpacing({required this.scale}); 9 + 10 + factory DensitySpacing.fromDensity(UiDensity density) => DensitySpacing(scale: density.scaleFactor); 11 + 12 + /// The multiplier applied to all spacing values (0.75 / 1.0 / 1.25). 13 + final double scale; 14 + 15 + double get xs => 4.0 * scale; 16 + double get sm => 8.0 * scale; 17 + double get md => 16.0 * scale; 18 + double get lg => 24.0 * scale; 19 + double get xl => 32.0 * scale; 20 + double get xxl => 48.0 * scale; 21 + 22 + @override 23 + DensitySpacing copyWith({double? scale}) => DensitySpacing(scale: scale ?? this.scale); 24 + 25 + @override 26 + DensitySpacing lerp(ThemeExtension<DensitySpacing>? other, double t) { 27 + if (other is! DensitySpacing) return this; 28 + return DensitySpacing(scale: scale + (other.scale - scale) * t); 29 + } 30 + 31 + @override 32 + bool operator ==(Object other) => 33 + identical(this, other) || other is DensitySpacing && runtimeType == other.runtimeType && scale == other.scale; 34 + 35 + @override 36 + int get hashCode => scale.hashCode; 37 + }
+13
lib/core/theme/feed_architecture.dart
··· 1 + enum FeedArchitecture { 2 + grid, 3 + linear; 4 + 5 + static FeedArchitecture fromString(String? value) { 6 + switch (value) { 7 + case 'linear': 8 + return FeedArchitecture.linear; 9 + default: 10 + return FeedArchitecture.grid; 11 + } 12 + } 13 + }
+27
lib/core/theme/ui_density.dart
··· 1 + enum UiDensity { 2 + compact, 3 + standard, 4 + relaxed; 5 + 6 + double get scaleFactor { 7 + switch (this) { 8 + case UiDensity.compact: 9 + return 0.75; 10 + case UiDensity.standard: 11 + return 1.0; 12 + case UiDensity.relaxed: 13 + return 1.25; 14 + } 15 + } 16 + 17 + static UiDensity fromString(String? value) { 18 + switch (value) { 19 + case 'compact': 20 + return UiDensity.compact; 21 + case 'relaxed': 22 + return UiDensity.relaxed; 23 + default: 24 + return UiDensity.standard; 25 + } 26 + } 27 + }
+22
lib/features/settings/bloc/settings_cubit.dart
··· 1 1 import 'package:flutter_bloc/flutter_bloc.dart'; 2 2 import 'package:lazurite/core/database/app_database.dart'; 3 3 import 'package:lazurite/core/theme/app_theme.dart'; 4 + import 'package:lazurite/core/theme/feed_architecture.dart'; 5 + import 'package:lazurite/core/theme/ui_density.dart'; 4 6 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 5 7 6 8 class SettingsCubit extends Cubit<SettingsState> { ··· 9 11 AppThemePalette? initialPalette, 10 12 AppThemeVariant? initialVariant, 11 13 bool? initialUseSystemTheme, 14 + UiDensity? initialUiDensity, 15 + FeedArchitecture? initialFeedArchitecture, 12 16 }) : super( 13 17 SettingsState( 14 18 themePalette: initialPalette ?? AppThemePalette.oxocarbon, 15 19 themeVariant: initialVariant ?? AppThemeVariant.dark, 16 20 useSystemTheme: initialUseSystemTheme ?? false, 21 + uiDensity: initialUiDensity ?? UiDensity.standard, 22 + feedArchitecture: initialFeedArchitecture ?? FeedArchitecture.grid, 17 23 ), 18 24 ); 19 25 ··· 22 28 static const String _keyThemePalette = 'theme_palette'; 23 29 static const String _keyThemeVariant = 'theme_variant'; 24 30 static const String _keyUseSystemTheme = 'use_system_theme'; 31 + static const String _keyUiDensity = 'ui_density'; 32 + static const String _keyFeedArchitecture = 'feed_architecture'; 25 33 26 34 Future<void> loadSettings() async { 27 35 final paletteStr = await database.getSetting(_keyThemePalette); 28 36 final variantStr = await database.getSetting(_keyThemeVariant); 29 37 final useSystemStr = await database.getSetting(_keyUseSystemTheme); 38 + final uiDensityStr = await database.getSetting(_keyUiDensity); 39 + final feedArchStr = await database.getSetting(_keyFeedArchitecture); 30 40 31 41 emit( 32 42 state.copyWith( 33 43 themePalette: AppTheme.parsePalette(paletteStr), 34 44 themeVariant: AppTheme.parseVariant(variantStr), 35 45 useSystemTheme: useSystemStr == 'true', 46 + uiDensity: UiDensity.fromString(uiDensityStr), 47 + feedArchitecture: FeedArchitecture.fromString(feedArchStr), 36 48 ), 37 49 ); 38 50 } ··· 56 68 Future<void> setUseSystemTheme(bool value) async { 57 69 await database.setSetting(_keyUseSystemTheme, value.toString()); 58 70 emit(state.copyWith(useSystemTheme: value)); 71 + } 72 + 73 + Future<void> setUiDensity(UiDensity density) async { 74 + await database.setSetting(_keyUiDensity, density.name); 75 + emit(state.copyWith(uiDensity: density)); 76 + } 77 + 78 + Future<void> setFeedArchitecture(FeedArchitecture architecture) async { 79 + await database.setSetting(_keyFeedArchitecture, architecture.name); 80 + emit(state.copyWith(feedArchitecture: architecture)); 59 81 } 60 82 }
+26 -4
lib/features/settings/bloc/settings_state.dart
··· 1 1 import 'package:equatable/equatable.dart'; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:lazurite/core/theme/app_theme.dart'; 4 + import 'package:lazurite/core/theme/density_spacing.dart'; 5 + import 'package:lazurite/core/theme/feed_architecture.dart'; 6 + import 'package:lazurite/core/theme/ui_density.dart'; 4 7 5 8 class SettingsState extends Equatable { 6 - const SettingsState({required this.themePalette, required this.themeVariant, required this.useSystemTheme}); 9 + const SettingsState({ 10 + required this.themePalette, 11 + required this.themeVariant, 12 + required this.useSystemTheme, 13 + this.uiDensity = UiDensity.standard, 14 + this.feedArchitecture = FeedArchitecture.grid, 15 + }); 7 16 8 17 final AppThemePalette themePalette; 9 18 final AppThemeVariant themeVariant; 10 19 final bool useSystemTheme; 20 + final UiDensity uiDensity; 21 + final FeedArchitecture feedArchitecture; 11 22 12 - ThemeData get themeData => AppTheme.getTheme(themePalette, themeVariant); 23 + ThemeData get themeData { 24 + final base = AppTheme.getTheme(themePalette, themeVariant); 25 + return base.copyWith(extensions: [DensitySpacing.fromDensity(uiDensity)]); 26 + } 13 27 14 - SettingsState copyWith({AppThemePalette? themePalette, AppThemeVariant? themeVariant, bool? useSystemTheme}) { 28 + SettingsState copyWith({ 29 + AppThemePalette? themePalette, 30 + AppThemeVariant? themeVariant, 31 + bool? useSystemTheme, 32 + UiDensity? uiDensity, 33 + FeedArchitecture? feedArchitecture, 34 + }) { 15 35 return SettingsState( 16 36 themePalette: themePalette ?? this.themePalette, 17 37 themeVariant: themeVariant ?? this.themeVariant, 18 38 useSystemTheme: useSystemTheme ?? this.useSystemTheme, 39 + uiDensity: uiDensity ?? this.uiDensity, 40 + feedArchitecture: feedArchitecture ?? this.feedArchitecture, 19 41 ); 20 42 } 21 43 22 44 @override 23 - List<Object?> get props => [themePalette, themeVariant, useSystemTheme]; 45 + List<Object?> get props => [themePalette, themeVariant, useSystemTheme, uiDensity, feedArchitecture]; 24 46 }
+122
test/core/theme/density_spacing_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/theme/density_spacing.dart'; 3 + import 'package:lazurite/core/theme/ui_density.dart'; 4 + 5 + void main() { 6 + group('DensitySpacing', () { 7 + group('fromDensity', () { 8 + test('compact uses 0.75 scale', () { 9 + final spacing = DensitySpacing.fromDensity(UiDensity.compact); 10 + expect(spacing.scale, 0.75); 11 + }); 12 + 13 + test('standard uses 1.0 scale', () { 14 + final spacing = DensitySpacing.fromDensity(UiDensity.standard); 15 + expect(spacing.scale, 1.0); 16 + }); 17 + 18 + test('relaxed uses 1.25 scale', () { 19 + final spacing = DensitySpacing.fromDensity(UiDensity.relaxed); 20 + expect(spacing.scale, 1.25); 21 + }); 22 + }); 23 + 24 + group('spacing values at standard (1.0) scale', () { 25 + late DensitySpacing spacing; 26 + 27 + setUp(() { 28 + spacing = DensitySpacing.fromDensity(UiDensity.standard); 29 + }); 30 + 31 + test('xs is 4.0', () => expect(spacing.xs, 4.0)); 32 + test('sm is 8.0', () => expect(spacing.sm, 8.0)); 33 + test('md is 16.0', () => expect(spacing.md, 16.0)); 34 + test('lg is 24.0', () => expect(spacing.lg, 24.0)); 35 + test('xl is 32.0', () => expect(spacing.xl, 32.0)); 36 + test('xxl is 48.0', () => expect(spacing.xxl, 48.0)); 37 + }); 38 + 39 + group('spacing values scale correctly', () { 40 + test('compact halves relative to relaxed', () { 41 + final compact = DensitySpacing.fromDensity(UiDensity.compact); 42 + final relaxed = DensitySpacing.fromDensity(UiDensity.relaxed); 43 + expect(compact.md, lessThan(relaxed.md)); 44 + }); 45 + 46 + test('all spacing values are proportional to scale', () { 47 + const scale = 2.0; 48 + const spacing = DensitySpacing(scale: scale); 49 + expect(spacing.xs, 4.0 * scale); 50 + expect(spacing.sm, 8.0 * scale); 51 + expect(spacing.md, 16.0 * scale); 52 + expect(spacing.lg, 24.0 * scale); 53 + expect(spacing.xl, 32.0 * scale); 54 + expect(spacing.xxl, 48.0 * scale); 55 + }); 56 + }); 57 + 58 + group('copyWith', () { 59 + test('returns new instance with updated scale', () { 60 + const original = DensitySpacing(scale: 1.0); 61 + final copy = original.copyWith(scale: 0.5); 62 + expect(copy.scale, 0.5); 63 + expect(original.scale, 1.0); 64 + }); 65 + 66 + test('preserves scale when not provided', () { 67 + const original = DensitySpacing(scale: 1.25); 68 + final copy = original.copyWith(); 69 + expect(copy.scale, 1.25); 70 + }); 71 + }); 72 + 73 + group('lerp', () { 74 + test('lerps scale between two instances', () { 75 + const a = DensitySpacing(scale: 0.75); 76 + const b = DensitySpacing(scale: 1.25); 77 + final mid = a.lerp(b, 0.5); 78 + expect(mid.scale, closeTo(1.0, 0.001)); 79 + }); 80 + 81 + test('lerp at t=0 returns self values', () { 82 + const a = DensitySpacing(scale: 0.75); 83 + const b = DensitySpacing(scale: 1.25); 84 + final result = a.lerp(b, 0.0); 85 + expect(result.scale, 0.75); 86 + }); 87 + 88 + test('lerp at t=1 returns other values', () { 89 + const a = DensitySpacing(scale: 0.75); 90 + const b = DensitySpacing(scale: 1.25); 91 + final result = a.lerp(b, 1.0); 92 + expect(result.scale, 1.25); 93 + }); 94 + 95 + test('lerp with null returns self', () { 96 + const a = DensitySpacing(scale: 1.0); 97 + final result = a.lerp(null, 0.5); 98 + expect(result, a); 99 + }); 100 + }); 101 + 102 + group('equality', () { 103 + test('equal when scale is the same', () { 104 + const a = DensitySpacing(scale: 1.0); 105 + const b = DensitySpacing(scale: 1.0); 106 + expect(a, equals(b)); 107 + }); 108 + 109 + test('not equal when scale differs', () { 110 + const a = DensitySpacing(scale: 1.0); 111 + const b = DensitySpacing(scale: 1.25); 112 + expect(a, isNot(equals(b))); 113 + }); 114 + 115 + test('hashCode is equal for same scale', () { 116 + const a = DensitySpacing(scale: 1.0); 117 + const b = DensitySpacing(scale: 1.0); 118 + expect(a.hashCode, b.hashCode); 119 + }); 120 + }); 121 + }); 122 + }
+30
test/core/theme/feed_architecture_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/theme/feed_architecture.dart'; 3 + 4 + void main() { 5 + group('FeedArchitecture', () { 6 + group('fromString', () { 7 + test('parses grid', () { 8 + expect(FeedArchitecture.fromString('grid'), FeedArchitecture.grid); 9 + }); 10 + 11 + test('parses linear', () { 12 + expect(FeedArchitecture.fromString('linear'), FeedArchitecture.linear); 13 + }); 14 + 15 + test('null returns grid', () { 16 + expect(FeedArchitecture.fromString(null), FeedArchitecture.grid); 17 + }); 18 + 19 + test('unknown value returns grid', () { 20 + expect(FeedArchitecture.fromString('unknown'), FeedArchitecture.grid); 21 + }); 22 + 23 + test('round-trips all values via name', () { 24 + for (final arch in FeedArchitecture.values) { 25 + expect(FeedArchitecture.fromString(arch.name), arch, reason: 'arch: $arch'); 26 + } 27 + }); 28 + }); 29 + }); 30 + }
+56
test/core/theme/ui_density_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/theme/ui_density.dart'; 3 + 4 + void main() { 5 + group('UiDensity', () { 6 + group('scaleFactor', () { 7 + test('compact has scale 0.75', () { 8 + expect(UiDensity.compact.scaleFactor, 0.75); 9 + }); 10 + 11 + test('standard has scale 1.0', () { 12 + expect(UiDensity.standard.scaleFactor, 1.0); 13 + }); 14 + 15 + test('relaxed has scale 1.25', () { 16 + expect(UiDensity.relaxed.scaleFactor, 1.25); 17 + }); 18 + 19 + test('compact scale is less than standard', () { 20 + expect(UiDensity.compact.scaleFactor, lessThan(UiDensity.standard.scaleFactor)); 21 + }); 22 + 23 + test('relaxed scale is greater than standard', () { 24 + expect(UiDensity.relaxed.scaleFactor, greaterThan(UiDensity.standard.scaleFactor)); 25 + }); 26 + }); 27 + 28 + group('fromString', () { 29 + test('parses compact', () { 30 + expect(UiDensity.fromString('compact'), UiDensity.compact); 31 + }); 32 + 33 + test('parses standard', () { 34 + expect(UiDensity.fromString('standard'), UiDensity.standard); 35 + }); 36 + 37 + test('parses relaxed', () { 38 + expect(UiDensity.fromString('relaxed'), UiDensity.relaxed); 39 + }); 40 + 41 + test('null returns standard', () { 42 + expect(UiDensity.fromString(null), UiDensity.standard); 43 + }); 44 + 45 + test('unknown value returns standard', () { 46 + expect(UiDensity.fromString('unknown'), UiDensity.standard); 47 + }); 48 + 49 + test('round-trips all values via name', () { 50 + for (final density in UiDensity.values) { 51 + expect(UiDensity.fromString(density.name), density, reason: 'density: $density'); 52 + } 53 + }); 54 + }); 55 + }); 56 + }
+77 -2
test/features/settings/bloc/settings_cubit_test.dart
··· 3 3 import 'package:flutter_test/flutter_test.dart'; 4 4 import 'package:lazurite/core/database/app_database.dart'; 5 5 import 'package:lazurite/core/theme/app_theme.dart'; 6 + import 'package:lazurite/core/theme/feed_architecture.dart'; 7 + import 'package:lazurite/core/theme/ui_density.dart'; 6 8 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 7 9 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 8 10 ··· 23 25 expect(cubit.state.themePalette, AppThemePalette.oxocarbon); 24 26 expect(cubit.state.themeVariant, AppThemeVariant.dark); 25 27 expect(cubit.state.useSystemTheme, false); 28 + expect(cubit.state.uiDensity, UiDensity.standard); 29 + expect(cubit.state.feedArchitecture, FeedArchitecture.grid); 26 30 }); 27 31 28 32 test('accepts initial values via constructor', () { ··· 31 35 initialPalette: AppThemePalette.catppuccin, 32 36 initialVariant: AppThemeVariant.light, 33 37 initialUseSystemTheme: true, 38 + initialUiDensity: UiDensity.compact, 39 + initialFeedArchitecture: FeedArchitecture.linear, 34 40 ); 35 41 expect(cubit.state.themePalette, AppThemePalette.catppuccin); 36 42 expect(cubit.state.themeVariant, AppThemeVariant.light); 37 43 expect(cubit.state.useSystemTheme, true); 44 + expect(cubit.state.uiDensity, UiDensity.compact); 45 + expect(cubit.state.feedArchitecture, FeedArchitecture.linear); 38 46 }); 39 47 40 48 blocTest<SettingsCubit, SettingsState>( ··· 44 52 await database.setSetting('theme_palette', 'nord'); 45 53 await database.setSetting('theme_variant', 'light'); 46 54 await database.setSetting('use_system_theme', 'true'); 55 + await database.setSetting('ui_density', 'compact'); 56 + await database.setSetting('feed_architecture', 'linear'); 47 57 }, 48 58 act: (cubit) => cubit.loadSettings(), 49 59 expect: () => [ 50 60 isA<SettingsState>() 51 61 .having((s) => s.themePalette, 'themePalette', AppThemePalette.nord) 52 62 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.light) 53 - .having((s) => s.useSystemTheme, 'useSystemTheme', true), 63 + .having((s) => s.useSystemTheme, 'useSystemTheme', true) 64 + .having((s) => s.uiDensity, 'uiDensity', UiDensity.compact) 65 + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), 54 66 ], 55 67 ); 56 68 ··· 62 74 isA<SettingsState>() 63 75 .having((s) => s.themePalette, 'themePalette', AppThemePalette.oxocarbon) 64 76 .having((s) => s.themeVariant, 'themeVariant', AppThemeVariant.dark) 65 - .having((s) => s.useSystemTheme, 'useSystemTheme', false), 77 + .having((s) => s.useSystemTheme, 'useSystemTheme', false) 78 + .having((s) => s.uiDensity, 'uiDensity', UiDensity.standard) 79 + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid), 66 80 ], 67 81 ); 68 82 ··· 112 126 final value = await database.getSetting('use_system_theme'); 113 127 expect(value, 'true'); 114 128 }, 129 + ); 130 + 131 + blocTest<SettingsCubit, SettingsState>( 132 + 'setUiDensity updates state and persists to database', 133 + build: () => SettingsCubit(database: database), 134 + act: (cubit) => cubit.setUiDensity(UiDensity.compact), 135 + expect: () => [isA<SettingsState>().having((s) => s.uiDensity, 'uiDensity', UiDensity.compact)], 136 + verify: (cubit) async { 137 + final value = await database.getSetting('ui_density'); 138 + expect(value, 'compact'); 139 + }, 140 + ); 141 + 142 + blocTest<SettingsCubit, SettingsState>( 143 + 'setUiDensity relaxed updates state and persists to database', 144 + build: () => SettingsCubit(database: database), 145 + act: (cubit) => cubit.setUiDensity(UiDensity.relaxed), 146 + expect: () => [isA<SettingsState>().having((s) => s.uiDensity, 'uiDensity', UiDensity.relaxed)], 147 + verify: (cubit) async { 148 + final value = await database.getSetting('ui_density'); 149 + expect(value, 'relaxed'); 150 + }, 151 + ); 152 + 153 + blocTest<SettingsCubit, SettingsState>( 154 + 'setFeedArchitecture updates state and persists to database', 155 + build: () => SettingsCubit(database: database), 156 + act: (cubit) => cubit.setFeedArchitecture(FeedArchitecture.linear), 157 + expect: () => [ 158 + isA<SettingsState>().having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), 159 + ], 160 + verify: (cubit) async { 161 + final value = await database.getSetting('feed_architecture'); 162 + expect(value, 'linear'); 163 + }, 164 + ); 165 + 166 + blocTest<SettingsCubit, SettingsState>( 167 + 'setFeedArchitecture grid updates state and persists to database', 168 + build: () => SettingsCubit(database: database, initialFeedArchitecture: FeedArchitecture.linear), 169 + act: (cubit) => cubit.setFeedArchitecture(FeedArchitecture.grid), 170 + expect: () => [isA<SettingsState>().having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.grid)], 171 + verify: (cubit) async { 172 + final value = await database.getSetting('feed_architecture'); 173 + expect(value, 'grid'); 174 + }, 175 + ); 176 + 177 + blocTest<SettingsCubit, SettingsState>( 178 + 'loadSettings round-trips ui_density and feed_architecture', 179 + build: () => SettingsCubit(database: database), 180 + setUp: () async { 181 + await database.setSetting('ui_density', 'relaxed'); 182 + await database.setSetting('feed_architecture', 'linear'); 183 + }, 184 + act: (cubit) => cubit.loadSettings(), 185 + expect: () => [ 186 + isA<SettingsState>() 187 + .having((s) => s.uiDensity, 'uiDensity', UiDensity.relaxed) 188 + .having((s) => s.feedArchitecture, 'feedArchitecture', FeedArchitecture.linear), 189 + ], 115 190 ); 116 191 }); 117 192 }
+66
test/features/settings/bloc/settings_state_test.dart
··· 1 1 import 'package:flutter_test/flutter_test.dart'; 2 2 import 'package:lazurite/core/theme/app_theme.dart'; 3 + import 'package:lazurite/core/theme/feed_architecture.dart'; 4 + import 'package:lazurite/core/theme/ui_density.dart'; 3 5 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 4 6 5 7 void main() { ··· 64 66 expect(state1, isNot(equals(state2))); 65 67 }); 66 68 69 + test('inequality when uiDensity differs', () { 70 + const state1 = SettingsState( 71 + themePalette: AppThemePalette.oxocarbon, 72 + themeVariant: AppThemeVariant.dark, 73 + useSystemTheme: false, 74 + uiDensity: UiDensity.standard, 75 + ); 76 + const state2 = SettingsState( 77 + themePalette: AppThemePalette.oxocarbon, 78 + themeVariant: AppThemeVariant.dark, 79 + useSystemTheme: false, 80 + uiDensity: UiDensity.compact, 81 + ); 82 + 83 + expect(state1, isNot(equals(state2))); 84 + }); 85 + 86 + test('inequality when feedArchitecture differs', () { 87 + const state1 = SettingsState( 88 + themePalette: AppThemePalette.oxocarbon, 89 + themeVariant: AppThemeVariant.dark, 90 + useSystemTheme: false, 91 + feedArchitecture: FeedArchitecture.grid, 92 + ); 93 + const state2 = SettingsState( 94 + themePalette: AppThemePalette.oxocarbon, 95 + themeVariant: AppThemeVariant.dark, 96 + useSystemTheme: false, 97 + feedArchitecture: FeedArchitecture.linear, 98 + ); 99 + 100 + expect(state1, isNot(equals(state2))); 101 + }); 102 + 67 103 test('copyWith returns new instance with updated values', () { 68 104 const original = SettingsState( 69 105 themePalette: AppThemePalette.oxocarbon, ··· 75 111 themePalette: AppThemePalette.nord, 76 112 themeVariant: AppThemeVariant.light, 77 113 useSystemTheme: true, 114 + uiDensity: UiDensity.compact, 115 + feedArchitecture: FeedArchitecture.linear, 78 116 ); 79 117 80 118 expect(updated.themePalette, AppThemePalette.nord); 81 119 expect(updated.themeVariant, AppThemeVariant.light); 82 120 expect(updated.useSystemTheme, true); 121 + expect(updated.uiDensity, UiDensity.compact); 122 + expect(updated.feedArchitecture, FeedArchitecture.linear); 83 123 expect(original.themePalette, AppThemePalette.oxocarbon); 84 124 }); 85 125 ··· 88 128 themePalette: AppThemePalette.catppuccin, 89 129 themeVariant: AppThemeVariant.light, 90 130 useSystemTheme: true, 131 + uiDensity: UiDensity.relaxed, 132 + feedArchitecture: FeedArchitecture.linear, 91 133 ); 92 134 93 135 final updated = original.copyWith(); ··· 95 137 expect(updated.themePalette, AppThemePalette.catppuccin); 96 138 expect(updated.themeVariant, AppThemeVariant.light); 97 139 expect(updated.useSystemTheme, true); 140 + expect(updated.uiDensity, UiDensity.relaxed); 141 + expect(updated.feedArchitecture, FeedArchitecture.linear); 98 142 }); 99 143 100 144 test('props includes all fields', () { ··· 102 146 themePalette: AppThemePalette.rosePine, 103 147 themeVariant: AppThemeVariant.light, 104 148 useSystemTheme: true, 149 + uiDensity: UiDensity.compact, 150 + feedArchitecture: FeedArchitecture.linear, 105 151 ); 106 152 107 153 expect(state.props, contains(AppThemePalette.rosePine)); 108 154 expect(state.props, contains(AppThemeVariant.light)); 109 155 expect(state.props, contains(true)); 156 + expect(state.props, contains(UiDensity.compact)); 157 + expect(state.props, contains(FeedArchitecture.linear)); 158 + }); 159 + 160 + test('defaults uiDensity to standard', () { 161 + const state = SettingsState( 162 + themePalette: AppThemePalette.oxocarbon, 163 + themeVariant: AppThemeVariant.dark, 164 + useSystemTheme: false, 165 + ); 166 + expect(state.uiDensity, UiDensity.standard); 167 + }); 168 + 169 + test('defaults feedArchitecture to grid', () { 170 + const state = SettingsState( 171 + themePalette: AppThemePalette.oxocarbon, 172 + themeVariant: AppThemeVariant.dark, 173 + useSystemTheme: false, 174 + ); 175 + expect(state.feedArchitecture, FeedArchitecture.grid); 110 176 }); 111 177 }); 112 178 }