diff --git a/docs/progress.md b/docs/progress.md index 698000f..8377843 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,31 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Cross-View Responsive Pass + +Audited and adjusted the main product views for mobile-first behavior while preserving desktop/web UX: + +- Updated responsive behavior and compact spacing for: + - `lib/features/dashboard/dashboard_view.dart` + - `lib/features/moments/moments_view.dart` + - `lib/features/ideas/ideas_view.dart` + - `lib/features/reminders/reminders_view.dart` + - `lib/features/signals/signals_view.dart` + - `lib/features/settings/settings_view.dart` + - `lib/features/sync/sync_view.dart` + - `lib/features/auth/sign_in_view.dart` + - `lib/features/people/people_view.dart` +- Removed narrow-width overflow hotspots: + - stacked compact headers/actions instead of single fixed rows + - converted key action rows to `Wrap`/column where needed + - improved list-item text constraints (`maxLines`, `ellipsis`) + - made editor dialogs width-safe using max-width constraints + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): People Mobile Layout Fix Fixed the `People` view so it is mobile-friendly and no longer overflows on iOS narrow screens: diff --git a/lib/features/auth/sign_in_view.dart b/lib/features/auth/sign_in_view.dart index c6d0580..c8109fc 100644 --- a/lib/features/auth/sign_in_view.dart +++ b/lib/features/auth/sign_in_view.dart @@ -45,107 +45,141 @@ class _SignInViewState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(24), child: FrostedCard( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sign in to continue', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 8), - Text( - 'Your local data remains available offline. Backend unlocks sync and signals.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 18), - SegmentedButton( - segments: const >[ - ButtonSegment( - value: SignInMethod.password, - label: Text('Password'), - ), - ButtonSegment( - value: SignInMethod.emailMagicLink, - label: Text('Magic Link'), - ), - ButtonSegment( - value: SignInMethod.oidc, - label: Text('OIDC'), - ), - ], - selected: {_method}, - onSelectionChanged: busy - ? null - : (Set value) { - setState(() { - _method = value.first; - }); - }, - ), - const SizedBox(height: 14), - if (_method == SignInMethod.password || - _method == SignInMethod.emailMagicLink) - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - enabled: !busy, - decoration: const InputDecoration(labelText: 'Email'), - ), - if (_method == SignInMethod.password) - TextField( - controller: _passwordController, - obscureText: true, - enabled: !busy, - decoration: const InputDecoration(labelText: 'Password'), - ), - if (_method == SignInMethod.oidc) - TextField( - controller: _idTokenController, - enabled: !busy, - decoration: const InputDecoration(labelText: 'ID Token'), - ), - if (error != null) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - 'Sign-in failed: $error', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: const Color(0xFFC83030), - ), - ), - ), - const SizedBox(height: 16), - Row( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 480; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - FilledButton.icon( - onPressed: busy ? null : _submit, - icon: busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.login_rounded), - label: const Text('Sign In'), - ), - const SizedBox(width: 12), Text( - AppConfig.useFakeBackend - ? 'Fake mode enabled' - : 'REST mode (${AppConfig.backendBaseUrl})', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + 'Sign in to continue', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Your local data remains available offline. Backend unlocks sync and signals.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.textSecondary, ), ), + const SizedBox(height: 18), + if (compact) + DropdownButtonFormField( + initialValue: _method, + items: SignInMethod.values + .map( + (SignInMethod method) => + DropdownMenuItem( + value: method, + child: Text(_labelForMethod(method)), + ), + ) + .toList(growable: false), + onChanged: busy + ? null + : (SignInMethod? value) { + if (value == null) { + return; + } + setState(() { + _method = value; + }); + }, + decoration: const InputDecoration(labelText: 'Method'), + ) + else + SegmentedButton( + segments: const >[ + ButtonSegment( + value: SignInMethod.password, + label: Text('Password'), + ), + ButtonSegment( + value: SignInMethod.emailMagicLink, + label: Text('Magic Link'), + ), + ButtonSegment( + value: SignInMethod.oidc, + label: Text('OIDC'), + ), + ], + selected: {_method}, + onSelectionChanged: busy + ? null + : (Set value) { + setState(() { + _method = value.first; + }); + }, + ), + const SizedBox(height: 14), + if (_method == SignInMethod.password || + _method == SignInMethod.emailMagicLink) + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + enabled: !busy, + decoration: const InputDecoration(labelText: 'Email'), + ), + if (_method == SignInMethod.password) + TextField( + controller: _passwordController, + obscureText: true, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'Password', + ), + ), + if (_method == SignInMethod.oidc) + TextField( + controller: _idTokenController, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'ID Token', + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + 'Sign-in failed: $error', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: const Color(0xFFC83030)), + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + onPressed: busy ? null : _submit, + icon: busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.login_rounded), + label: const Text('Sign In'), + ), + Text( + AppConfig.useFakeBackend + ? 'Fake mode enabled' + : 'REST mode (${AppConfig.backendBaseUrl})', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), ], - ), - ], + ); + }, ), ), ), @@ -173,4 +207,12 @@ class _SignInViewState extends ConsumerState { idToken: idToken, ); } + + String _labelForMethod(SignInMethod method) { + return switch (method) { + SignInMethod.password => 'Password', + SignInMethod.emailMagicLink => 'Magic Link', + SignInMethod.oidc => 'OIDC', + }; + } } diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 3d3e3b4..28e2381 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -19,71 +19,89 @@ class DashboardView extends ConsumerWidget { final DashboardSummary summary = data.summary(); final List tasks = data.tasks; - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 36), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Today', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 8), - Text( - 'Focus on consistency over intensity. Small moments compound.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 720; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 24 : 36, ), - const SizedBox(height: 22), - Wrap( - spacing: 16, - runSpacing: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _StatCard( - label: 'Active People', - value: '${summary.activePeople}', - accent: const Color(0xFF16B8CA), + Text( + 'Today', + style: Theme.of(context).textTheme.headlineMedium, ), - _StatCard( - label: 'Upcoming Plans', - value: '${summary.upcomingPlans}', - accent: const Color(0xFF2B7FFF), + const SizedBox(height: 8), + Text( + 'Focus on consistency over intensity. Small moments compound.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), ), - _StatCard( - label: 'Pending Ideas', - value: '${summary.pendingIdeas}', - accent: const Color(0xFFFFA447), + const SizedBox(height: 22), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _StatCard( + label: 'Active People', + value: '${summary.activePeople}', + accent: const Color(0xFF16B8CA), + compact: compact, + ), + _StatCard( + label: 'Upcoming Plans', + value: '${summary.upcomingPlans}', + accent: const Color(0xFF2B7FFF), + compact: compact, + ), + _StatCard( + label: 'Pending Ideas', + value: '${summary.pendingIdeas}', + accent: const Color(0xFFFFA447), + compact: compact, + ), + _StatCard( + label: 'Weekly Rhythm', + value: '${summary.weeklyConsistency}%', + accent: const Color(0xFF30B66A), + compact: compact, + ), + ], ), - _StatCard( - label: 'Weekly Rhythm', - value: '${summary.weeklyConsistency}%', - accent: const Color(0xFF30B66A), + const SizedBox(height: 24), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Next Moves', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 14), + ...tasks.map( + (DashboardTask task) => _TaskRow( + task: task, + compact: compact, + onToggleDone: () { + ref + .read(localRepositoryProvider.notifier) + .toggleTaskDone(task.id); + }, + ), + ), + ], + ), ), ], ), - const SizedBox(height: 24), - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Next Moves', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 14), - ...tasks.map( - (DashboardTask task) => _TaskRow( - task: task, - onToggleDone: () { - ref - .read(localRepositoryProvider.notifier) - .toggleTaskDone(task.id); - }, - ), - ), - ], - ), - ), - ], - ), + ); + }, ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -104,18 +122,20 @@ class _StatCard extends StatelessWidget { required this.label, required this.value, required this.accent, + required this.compact, }); final String label; final String value; final Color accent; + final bool compact; @override Widget build(BuildContext context) { return FrostedCard( padding: const EdgeInsets.all(16), child: SizedBox( - width: 188, + width: compact ? double.infinity : 188, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -149,10 +169,15 @@ class _StatCard extends StatelessWidget { } class _TaskRow extends StatelessWidget { - const _TaskRow({required this.task, required this.onToggleDone}); + const _TaskRow({ + required this.task, + required this.onToggleDone, + required this.compact, + }); final DashboardTask task; final VoidCallback onToggleDone; + final bool compact; @override Widget build(BuildContext context) { @@ -200,13 +225,15 @@ class _TaskRow extends StatelessWidget { ], ), ), - const SizedBox(width: 12), - Text( - _shortDate(task.when), - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), - ), + if (!compact) ...[ + const SizedBox(width: 12), + Text( + _shortDate(task.when), + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], ], ), ), diff --git a/lib/features/ideas/ideas_view.dart b/lib/features/ideas/ideas_view.dart index 6f455b0..b256f84 100644 --- a/lib/features/ideas/ideas_view.dart +++ b/lib/features/ideas/ideas_view.dart @@ -21,15 +21,21 @@ class IdeasView extends ConsumerWidget { for (final PersonProfile person in data.people) person.id: person, }; - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( + if (compact) + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -42,110 +48,268 @@ class IdeasView extends ConsumerWidget { style: Theme.of(context).textTheme.bodyLarge ?.copyWith(color: AppTheme.textSecondary), ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => _addIdea(context, ref, data.people), + icon: const Icon(Icons.lightbulb_outline_rounded), + label: const Text('Add Idea'), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ideas', + style: Theme.of( + context, + ).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Capture gift and event ideas before they slip away.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + FilledButton.icon( + onPressed: () => _addIdea(context, ref, data.people), + icon: const Icon(Icons.lightbulb_outline_rounded), + label: const Text('Add Idea'), + ), ], ), - ), - FilledButton.icon( - onPressed: () => _addIdea(context, ref, data.people), - icon: const Icon(Icons.lightbulb_outline_rounded), - label: const Text('Add Idea'), - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: ideas.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final RelationshipIdea idea = ideas[index]; - final PersonProfile? person = idea.personId == null - ? null - : peopleById[idea.personId!]; - return FrostedCard( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _IdeaTypeTag(type: idea.type), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - idea.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - decoration: idea.isArchived - ? TextDecoration.lineThrough - : null, + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: ideas.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final RelationshipIdea idea = ideas[index]; + final PersonProfile? person = idea.personId == null + ? null + : peopleById[idea.personId!]; + return FrostedCard( + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _IdeaTypeTag(type: idea.type), + const SizedBox(width: 8), + Expanded( + child: Text( + idea.title, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + decoration: idea.isArchived + ? TextDecoration + .lineThrough + : null, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (idea.details.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + idea.details, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), ), - ), - if (idea.details.trim().isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - idea.details, + const SizedBox(height: 8), + Text( + person == null + ? 'Unassigned' + : person.name, style: Theme.of(context) .textTheme - .bodyMedium + .labelLarge ?.copyWith( color: AppTheme.textSecondary, ), ), - ), - const SizedBox(height: 8), - Text( - person == null ? 'Unassigned' : person.name, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: AppTheme.textSecondary), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + OutlinedButton.icon( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .toggleIdeaArchived(idea.id); + }, + icon: Icon( + idea.isArchived + ? Icons.unarchive_outlined + : Icons.archive_outlined, + ), + label: Text( + idea.isArchived + ? 'Unarchive' + : 'Archive', + ), + ), + OutlinedButton.icon( + onPressed: () => _editIdea( + context, + ref, + data.people, + idea, + ), + icon: const Icon(Icons.edit_rounded), + label: const Text('Edit'), + ), + TextButton.icon( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteIdea(idea.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + label: const Text('Delete'), + ), + ], + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _IdeaTypeTag(type: idea.type), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + idea.title, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + decoration: idea.isArchived + ? TextDecoration + .lineThrough + : null, + ), + ), + if (idea.details.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only( + top: 6, + ), + child: Text( + idea.details, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme + .textSecondary, + ), + ), + ), + const SizedBox(height: 8), + Text( + person == null + ? 'Unassigned' + : person.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + children: [ + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .toggleIdeaArchived(idea.id); + }, + icon: Icon( + idea.isArchived + ? Icons.unarchive_outlined + : Icons.archive_outlined, + ), + tooltip: idea.isArchived + ? 'Unarchive' + : 'Archive', + ), + IconButton( + onPressed: () => _editIdea( + context, + ref, + data.people, + idea, + ), + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit', + ), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteIdea(idea.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete', + ), + ], + ), + ], ), - ], - ), - ), - const SizedBox(width: 8), - Column( - children: [ - IconButton( - onPressed: () { - ref - .read(localRepositoryProvider.notifier) - .toggleIdeaArchived(idea.id); - }, - icon: Icon( - idea.isArchived - ? Icons.unarchive_outlined - : Icons.archive_outlined, - ), - tooltip: idea.isArchived - ? 'Unarchive' - : 'Archive', - ), - IconButton( - onPressed: () => - _editIdea(context, ref, data.people, idea), - icon: const Icon(Icons.edit_rounded), - tooltip: 'Edit', - ), - IconButton( - onPressed: () { - ref - .read(localRepositoryProvider.notifier) - .deleteIdea(idea.id); - }, - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete', - ), - ], - ), - ], - ), - ); - }, - ), + ); + }, + ), + ), + ], ), - ], - ), + ); + }, ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -293,9 +457,9 @@ class _IdeaEditorDialogState extends State<_IdeaEditorDialog> { Widget build(BuildContext context) { return AlertDialog( title: Text(widget.title), - content: SizedBox( - width: 430, - child: SingleChildScrollView( + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 430), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/moments/moments_view.dart b/lib/features/moments/moments_view.dart index 35488ef..37de72e 100644 --- a/lib/features/moments/moments_view.dart +++ b/lib/features/moments/moments_view.dart @@ -44,157 +44,283 @@ class _MomentsViewState extends ConsumerState { ? _selectedPersonId : fallbackPersonId; - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Moments', - style: Theme.of(context).textTheme.headlineMedium, + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, ), - const SizedBox(height: 6), - Text( - 'Capture wins and signals from everyday interactions.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 16), - FrostedCard( - child: Column( - children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _captureController, - decoration: InputDecoration( - hintText: - 'Quick capture: what happened, how it felt, what to repeat...', - hintStyle: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - border: InputBorder.none, - ), - ), - ), - const SizedBox(width: 8), - if (people.isNotEmpty) - DropdownButton( - value: activePerson, - hint: const Text('Person'), - items: people - .map( - (PersonProfile person) => - DropdownMenuItem( - value: person.id, - child: Text(person.name), - ), - ) - .toList(growable: false), - onChanged: (String? value) { - setState(() { - _selectedPersonId = value; - }); - }, - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: people.isEmpty || activePerson == null - ? null - : () => _addCapture(activePerson), - icon: const Icon(Icons.add_rounded), - label: const Text('Add Capture'), - ), - ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Moments', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Capture wins and signals from everyday interactions.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, ), - if (people.isEmpty) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - 'Add people first before capturing moments.', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _captureController, + minLines: 1, + maxLines: compact ? 4 : 2, + decoration: InputDecoration( + hintText: + 'Quick capture: what happened, how it felt, what to repeat...', + hintStyle: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + border: InputBorder.none, + ), ), - ), - ], - ), - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: moments.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final RelationshipMoment moment = moments[index]; - final PersonProfile? person = peopleById[moment.personId]; - return FrostedCard( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(top: 6), - decoration: const BoxDecoration( - color: Color(0xFF1AB6C8), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - moment.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}', - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 8), - Text( - moment.summary, - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ), - const SizedBox(width: 12), + const SizedBox(height: 8), + if (compact) Column( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - '${moment.at.month}/${moment.at.day}', - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: AppTheme.textSecondary), + if (people.isNotEmpty) + DropdownButtonFormField( + initialValue: activePerson, + items: people + .map( + (PersonProfile person) => + DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + setState(() { + _selectedPersonId = value; + }); + }, + decoration: const InputDecoration( + labelText: 'Person', + ), + ), + const SizedBox(height: 10), + FilledButton.icon( + onPressed: + people.isEmpty || activePerson == null + ? null + : () => _addCapture(activePerson), + icon: const Icon(Icons.add_rounded), + label: const Text('Add Capture'), ), - const SizedBox(height: 8), - IconButton( - onPressed: () { - ref - .read(localRepositoryProvider.notifier) - .deleteMoment(moment.id); - }, - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete capture', + ], + ) + else + Row( + children: [ + if (people.isNotEmpty) + DropdownButton( + value: activePerson, + hint: const Text('Person'), + items: people + .map( + (PersonProfile person) => + DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + setState(() { + _selectedPersonId = value; + }); + }, + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: + people.isEmpty || activePerson == null + ? null + : () => _addCapture(activePerson), + icon: const Icon(Icons.add_rounded), + label: const Text('Add Capture'), ), ], ), - ], - ), - ); - }, - ), + if (people.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + 'Add people first before capturing moments.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: moments.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final RelationshipMoment moment = moments[index]; + final PersonProfile? person = + peopleById[moment.personId]; + return FrostedCard( + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Color(0xFF1AB6C8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + moment.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteMoment(moment.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete capture', + ), + ], + ), + Text( + '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()} • ${moment.at.month}/${moment.at.day}', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Text( + moment.summary, + style: Theme.of( + context, + ).textTheme.bodyLarge, + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(top: 6), + decoration: const BoxDecoration( + color: Color(0xFF1AB6C8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + moment.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Text( + moment.summary, + style: Theme.of( + context, + ).textTheme.bodyLarge, + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + '${moment.at.month}/${moment.at.day}', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteMoment(moment.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete capture', + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], ), - ], - ), + ); + }, ); }, loading: () => const Center(child: CircularProgressIndicator()), diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index ee2fc8a..3c43f90 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -585,8 +585,8 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> { return AlertDialog( title: Text(widget.title), content: SingleChildScrollView( - child: SizedBox( - width: 420, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/reminders/reminders_view.dart b/lib/features/reminders/reminders_view.dart index d3e627d..05ad676 100644 --- a/lib/features/reminders/reminders_view.dart +++ b/lib/features/reminders/reminders_view.dart @@ -21,15 +21,21 @@ class RemindersView extends ConsumerWidget { for (final PersonProfile person in data.people) person.id: person, }; - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( + if (compact) + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -42,90 +48,222 @@ class RemindersView extends ConsumerWidget { style: Theme.of(context).textTheme.bodyLarge ?.copyWith(color: AppTheme.textSecondary), ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => + _addReminder(context, ref, data.people), + icon: const Icon(Icons.alarm_add_rounded), + label: const Text('Add Reminder'), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reminders', + style: Theme.of( + context, + ).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Set recurring nudges so good intentions become habits.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + FilledButton.icon( + onPressed: () => + _addReminder(context, ref, data.people), + icon: const Icon(Icons.alarm_add_rounded), + label: const Text('Add Reminder'), + ), ], ), - ), - FilledButton.icon( - onPressed: () => _addReminder(context, ref, data.people), - icon: const Icon(Icons.alarm_add_rounded), - label: const Text('Add Reminder'), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: reminders.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final ReminderRule reminder = reminders[index]; + final PersonProfile? person = reminder.personId == null + ? null + : peopleById[reminder.personId!]; + + return FrostedCard( + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + reminder.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + Switch( + value: reminder.enabled, + onChanged: (_) { + ref + .read( + localRepositoryProvider + .notifier, + ) + .toggleReminderEnabled( + reminder.id, + ); + }, + ), + ], + ), + Text( + '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + person == null + ? 'Unassigned' + : person.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () => _editReminder( + context, + ref, + data.people, + reminder, + ), + icon: const Icon(Icons.edit_rounded), + label: const Text('Edit'), + ), + TextButton.icon( + onPressed: () { + ref + .read( + localRepositoryProvider + .notifier, + ) + .deleteReminder(reminder.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + label: const Text('Delete'), + ), + ], + ), + ], + ) + : Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + reminder.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + person == null + ? 'Unassigned' + : person.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + Switch( + value: reminder.enabled, + onChanged: (_) { + ref + .read( + localRepositoryProvider.notifier, + ) + .toggleReminderEnabled(reminder.id); + }, + ), + IconButton( + onPressed: () => _editReminder( + context, + ref, + data.people, + reminder, + ), + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit', + ), + IconButton( + onPressed: () { + ref + .read( + localRepositoryProvider.notifier, + ) + .deleteReminder(reminder.id); + }, + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Delete', + ), + ], + ), + ); + }, + ), ), ], ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: reminders.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final ReminderRule reminder = reminders[index]; - final PersonProfile? person = reminder.personId == null - ? null - : peopleById[reminder.personId!]; - - return FrostedCard( - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - reminder.title, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 4), - Text( - person == null ? 'Unassigned' : person.name, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - Switch( - value: reminder.enabled, - onChanged: (_) { - ref - .read(localRepositoryProvider.notifier) - .toggleReminderEnabled(reminder.id); - }, - ), - IconButton( - onPressed: () => _editReminder( - context, - ref, - data.people, - reminder, - ), - icon: const Icon(Icons.edit_rounded), - tooltip: 'Edit', - ), - IconButton( - onPressed: () { - ref - .read(localRepositoryProvider.notifier) - .deleteReminder(reminder.id); - }, - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete', - ), - ], - ), - ); - }, - ), - ), - ], - ), + ); + }, ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -238,9 +376,9 @@ class _ReminderEditorDialogState extends State<_ReminderEditorDialog> { Widget build(BuildContext context) { return AlertDialog( title: Text(widget.title), - content: SizedBox( - width: 420, - child: SingleChildScrollView( + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -291,20 +429,43 @@ class _ReminderEditorDialogState extends State<_ReminderEditorDialog> { decoration: const InputDecoration(labelText: 'Person'), ), const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Text( - _formatDateTime(_nextAt), - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - OutlinedButton.icon( - onPressed: _pickDateTime, - icon: const Icon(Icons.schedule_rounded), - label: const Text('Pick Time'), - ), - ], + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 360; + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDateTime(_nextAt), + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.schedule_rounded), + label: const Text('Pick Time'), + ), + ], + ); + } + + return Row( + children: [ + Expanded( + child: Text( + _formatDateTime(_nextAt), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + OutlinedButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.schedule_rounded), + label: const Text('Pick Time'), + ), + ], + ); + }, ), ], ), diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart index 87babae..ca90782 100644 --- a/lib/features/settings/settings_view.dart +++ b/lib/features/settings/settings_view.dart @@ -16,81 +16,106 @@ class SettingsView extends ConsumerWidget { .asData ?.value; - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Settings', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 6), - Text( - 'Environment and integration configuration for this build.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, ), - const SizedBox(height: 16), - FrostedCard( + child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SettingRow( - label: 'Signed in as', - value: - session?.user.email ?? - session?.user.id ?? - 'Not signed in', + Text( + 'Settings', + style: Theme.of(context).textTheme.headlineMedium, ), - const SizedBox(height: 12), - _SettingRow( - label: 'Gateway mode', - value: AppConfig.useFakeBackend - ? 'Fake (offline-safe)' - : 'REST', + const SizedBox(height: 6), + Text( + 'Environment and integration configuration for this build.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), ), - const SizedBox(height: 12), - _SettingRow(label: 'Base URL', value: AppConfig.backendBaseUrl), - const SizedBox(height: 12), - const _SettingRow( - label: 'Realtime', - value: 'Planned later (SSE/WebSocket)', + const SizedBox(height: 16), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingRow( + compact: compact, + label: 'Signed in as', + value: + session?.user.email ?? + session?.user.id ?? + 'Not signed in', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Gateway mode', + value: AppConfig.useFakeBackend + ? 'Fake (offline-safe)' + : 'REST', + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Base URL', + value: AppConfig.backendBaseUrl, + ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Realtime', + value: 'Planned later (SSE/WebSocket)', + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: session == null + ? null + : () => _signOut(context, ref), + icon: const Icon(Icons.logout_rounded), + label: const Text('Sign Out'), + ), + OutlinedButton.icon( + onPressed: session == null + ? null + : () { + ref + .read( + sessionControllerProvider.notifier, + ) + .refreshProfile(); + }, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Refresh Profile'), + ), + ], + ), + ], + ), ), - const SizedBox(height: 14), - Row( - children: [ - FilledButton.icon( - onPressed: session == null - ? null - : () => _signOut(context, ref), - icon: const Icon(Icons.logout_rounded), - label: const Text('Sign Out'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: session == null - ? null - : () { - ref - .read(sessionControllerProvider.notifier) - .refreshProfile(); - }, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Refresh Profile'), - ), - ], + const SizedBox(height: 16), + FrostedCard( + child: Text( + 'Run options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api', + style: Theme.of(context).textTheme.bodyLarge, + ), ), ], ), ), - const SizedBox(height: 16), - FrostedCard( - child: Text( - 'Run options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api', - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ], - ), + ); + }, ); } @@ -105,13 +130,34 @@ class SettingsView extends ConsumerWidget { } class _SettingRow extends StatelessWidget { - const _SettingRow({required this.label, required this.value}); + const _SettingRow({ + required this.label, + required this.value, + required this.compact, + }); final String label; final String value; + final bool compact; @override Widget build(BuildContext context) { + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ); + } + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/signals/signals_view.dart b/lib/features/signals/signals_view.dart index 695b80b..63f613e 100644 --- a/lib/features/signals/signals_view.dart +++ b/lib/features/signals/signals_view.dart @@ -14,15 +14,21 @@ class SignalsView extends ConsumerWidget { signalsControllerProvider, ); - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( + if (compact) + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -36,123 +42,170 @@ class SignalsView extends ConsumerWidget { color: AppTheme.textSecondary, ), ), + const SizedBox(height: 10), + IconButton.filledTonal( + onPressed: () { + ref.read(signalsControllerProvider.notifier).refresh(); + }, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh feed', + ), ], - ), - ), - IconButton.filledTonal( - onPressed: () { - ref.read(signalsControllerProvider.notifier).refresh(); - }, - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Refresh feed', - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: signals.when( - data: (SignalsFeed feed) { - if (feed.items.isEmpty) { - return const Center(child: Text('No signals right now.')); - } - - return ListView.separated( - itemCount: feed.items.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final SignalItem item = feed.items[index]; - return FrostedCard( + ) + else + Row( + children: [ + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFFEAF8FB), - borderRadius: BorderRadius.circular(99), - ), - child: Text( - item.type.toUpperCase(), - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( - color: AppTheme.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - const Spacer(), - Text( - '${item.createdAt.month}/${item.createdAt.day}', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - const SizedBox(height: 12), Text( - item.title, - style: Theme.of(context).textTheme.titleLarge, + 'Signals', + style: Theme.of(context).textTheme.headlineMedium, ), - if (item.description case final String description) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - description, - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - ), - const SizedBox(height: 14), - Row( - children: [ - OutlinedButton.icon( - onPressed: () { - ref - .read(signalsControllerProvider.notifier) - .acknowledge(item.id, SignalAction.saved); - }, - icon: const Icon(Icons.bookmark_add_outlined), - label: const Text('Save'), - ), - const SizedBox(width: 8), - TextButton.icon( - onPressed: () { - ref - .read(signalsControllerProvider.notifier) - .acknowledge( - item.id, - SignalAction.dismissed, - ); - }, - icon: const Icon(Icons.close_rounded), - label: const Text('Dismiss'), - ), - ], + const SizedBox(height: 6), + Text( + 'Backend-driven recommendations and trends you can act on.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), ), ], ), + ), + IconButton.filledTonal( + onPressed: () { + ref.read(signalsControllerProvider.notifier).refresh(); + }, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh feed', + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: signals.when( + data: (SignalsFeed feed) { + if (feed.items.isEmpty) { + return const Center(child: Text('No signals right now.')); + } + + return ListView.separated( + itemCount: feed.items.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final SignalItem item = feed.items[index]; + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFEAF8FB), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + item.type.toUpperCase(), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + color: AppTheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + Text( + '${item.createdAt.month}/${item.createdAt.day}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + item.title, + style: Theme.of(context).textTheme.titleLarge, + ), + if (item.description + case final String description) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + description, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + OutlinedButton.icon( + onPressed: () { + ref + .read( + signalsControllerProvider.notifier, + ) + .acknowledge( + item.id, + SignalAction.saved, + ); + }, + icon: const Icon( + Icons.bookmark_add_outlined, + ), + label: const Text('Save'), + ), + TextButton.icon( + onPressed: () { + ref + .read( + signalsControllerProvider.notifier, + ) + .acknowledge( + item.id, + SignalAction.dismissed, + ); + }, + icon: const Icon(Icons.close_rounded), + label: const Text('Dismiss'), + ), + ], + ), + ], + ), + ); + }, ); }, - ); - }, - error: (Object error, StackTrace stackTrace) { - return Center( - child: Text( - 'Unable to load signals. Try refresh.', - style: Theme.of(context).textTheme.bodyLarge, - ), - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - ), + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Unable to load signals. Try refresh.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + ), + ), + ], ), - ], - ), + ); + }, ); } } diff --git a/lib/features/sync/sync_view.dart b/lib/features/sync/sync_view.dart index 84f1a69..65b1b08 100644 --- a/lib/features/sync/sync_view.dart +++ b/lib/features/sync/sync_view.dart @@ -23,42 +23,54 @@ class _SyncViewState extends ConsumerState { syncQueueRepositoryProvider, ); - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Sync', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 6), - Text( - 'Queue local changes, then push and pull when network is available.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 760; + return Padding( + padding: EdgeInsets.fromLTRB( + compact ? 16 : 28, + compact ? 16 : 24, + compact ? 16 : 28, + compact ? 20 : 28, ), - const SizedBox(height: 16), - Expanded( - child: syncState.when( - data: (SyncState state) => _buildBody(context, state), - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stackTrace) { - return Center( - child: Text( - 'Unable to load sync state. $error', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ); - }, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sync', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Queue local changes, then push and pull when network is available.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + Expanded( + child: syncState.when( + data: (SyncState state) => + _buildBody(context, state, compact), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Unable to load sync state. $error', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + }, + ), + ), + ], ), - ], - ), + ); + }, ); } - Widget _buildBody(BuildContext context, SyncState state) { + Widget _buildBody(BuildContext context, SyncState state, bool compact) { return ListView( children: [ FrostedCard( @@ -111,12 +123,36 @@ class _SyncViewState extends ConsumerState { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 10), - _infoRow('Pending changes', '${state.pendingChanges.length}'), - _infoRow('Cursor', state.cursor ?? 'not set'), - _infoRow('Last sync', _formatDateTime(state.lastSyncAt)), - _infoRow('Last attempt', _formatDateTime(state.lastAttemptAt)), - _infoRow('Last rejected count', '${state.lastRejected.length}'), - _infoRow('Last error', state.lastError ?? 'none'), + _infoRow( + label: 'Pending changes', + value: '${state.pendingChanges.length}', + compact: compact, + ), + _infoRow( + label: 'Cursor', + value: state.cursor ?? 'not set', + compact: compact, + ), + _infoRow( + label: 'Last sync', + value: _formatDateTime(state.lastSyncAt), + compact: compact, + ), + _infoRow( + label: 'Last attempt', + value: _formatDateTime(state.lastAttemptAt), + compact: compact, + ), + _infoRow( + label: 'Last rejected count', + value: '${state.lastRejected.length}', + compact: compact, + ), + _infoRow( + label: 'Last error', + value: state.lastError ?? 'none', + compact: compact, + ), ], ), ), @@ -131,7 +167,30 @@ class _SyncViewState extends ConsumerState { ); } - Widget _infoRow(String label, String value) { + Widget _infoRow({ + required String label, + required String value, + required bool compact, + }) { + if (compact) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 2), + Text(value, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } + return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row(