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