Refine views for mobile-first and responsive layouts

This commit is contained in:
Rijad Zuzo
2026-02-15 17:20:52 +01:00
parent 4d21c6df82
commit 90c1a21d71
10 changed files with 1431 additions and 728 deletions
+25
View File
@@ -2,6 +2,31 @@
Updated: 2026-02-15 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 ## 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: Fixed the `People` view so it is mobile-friendly and no longer overflows on iOS narrow screens:
+137 -95
View File
@@ -45,107 +45,141 @@ class _SignInViewState extends ConsumerState<SignInView> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: FrostedCard( child: FrostedCard(
child: Column( child: LayoutBuilder(
mainAxisSize: MainAxisSize.min, builder: (BuildContext context, BoxConstraints constraints) {
crossAxisAlignment: CrossAxisAlignment.start, final bool compact = constraints.maxWidth < 480;
children: <Widget>[ return Column(
Text( mainAxisSize: MainAxisSize.min,
'Sign in to continue', crossAxisAlignment: CrossAxisAlignment.start,
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<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> 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(
children: <Widget>[ children: <Widget>[
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( Text(
AppConfig.useFakeBackend 'Sign in to continue',
? 'Fake mode enabled' style: Theme.of(context).textTheme.headlineMedium,
: 'REST mode (${AppConfig.backendBaseUrl})', ),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( 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, color: AppTheme.textSecondary,
), ),
), ),
const SizedBox(height: 18),
if (compact)
DropdownButtonFormField<SignInMethod>(
initialValue: _method,
items: SignInMethod.values
.map(
(SignInMethod method) =>
DropdownMenuItem<SignInMethod>(
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<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> 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: <Widget>[
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<SignInView> {
idToken: idToken, idToken: idToken,
); );
} }
String _labelForMethod(SignInMethod method) {
return switch (method) {
SignInMethod.password => 'Password',
SignInMethod.emailMagicLink => 'Magic Link',
SignInMethod.oidc => 'OIDC',
};
}
} }
+93 -66
View File
@@ -19,71 +19,89 @@ class DashboardView extends ConsumerWidget {
final DashboardSummary summary = data.summary(); final DashboardSummary summary = data.summary();
final List<DashboardTask> tasks = data.tasks; final List<DashboardTask> tasks = data.tasks;
return SingleChildScrollView( return LayoutBuilder(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 36), builder: (BuildContext context, BoxConstraints constraints) {
child: Column( final bool compact = constraints.maxWidth < 720;
crossAxisAlignment: CrossAxisAlignment.start, return SingleChildScrollView(
children: <Widget>[ padding: EdgeInsets.fromLTRB(
Text('Today', style: Theme.of(context).textTheme.headlineMedium), compact ? 16 : 28,
const SizedBox(height: 8), compact ? 16 : 24,
Text( compact ? 16 : 28,
'Focus on consistency over intensity. Small moments compound.', compact ? 24 : 36,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
), ),
const SizedBox(height: 22), child: Column(
Wrap( crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
runSpacing: 16,
children: <Widget>[ children: <Widget>[
_StatCard( Text(
label: 'Active People', 'Today',
value: '${summary.activePeople}', style: Theme.of(context).textTheme.headlineMedium,
accent: const Color(0xFF16B8CA),
), ),
_StatCard( const SizedBox(height: 8),
label: 'Upcoming Plans', Text(
value: '${summary.upcomingPlans}', 'Focus on consistency over intensity. Small moments compound.',
accent: const Color(0xFF2B7FFF), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
), ),
_StatCard( const SizedBox(height: 22),
label: 'Pending Ideas', Wrap(
value: '${summary.pendingIdeas}', spacing: 12,
accent: const Color(0xFFFFA447), runSpacing: 12,
children: <Widget>[
_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( const SizedBox(height: 24),
label: 'Weekly Rhythm', FrostedCard(
value: '${summary.weeklyConsistency}%', child: Column(
accent: const Color(0xFF30B66A), crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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()), loading: () => const Center(child: CircularProgressIndicator()),
@@ -104,18 +122,20 @@ class _StatCard extends StatelessWidget {
required this.label, required this.label,
required this.value, required this.value,
required this.accent, required this.accent,
required this.compact,
}); });
final String label; final String label;
final String value; final String value;
final Color accent; final Color accent;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FrostedCard( return FrostedCard(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: SizedBox( child: SizedBox(
width: 188, width: compact ? double.infinity : 188,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
@@ -149,10 +169,15 @@ class _StatCard extends StatelessWidget {
} }
class _TaskRow 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 DashboardTask task;
final VoidCallback onToggleDone; final VoidCallback onToggleDone;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -200,13 +225,15 @@ class _TaskRow extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 12), if (!compact) ...<Widget>[
Text( const SizedBox(width: 12),
_shortDate(task.when), Text(
style: Theme.of( _shortDate(task.when),
context, style: Theme.of(
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), context,
), ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
],
], ],
), ),
), ),
+268 -104
View File
@@ -21,15 +21,21 @@ class IdeasView extends ConsumerWidget {
for (final PersonProfile person in data.people) person.id: person, for (final PersonProfile person in data.people) person.id: person,
}; };
return Padding( return LayoutBuilder(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), builder: (BuildContext context, BoxConstraints constraints) {
child: Column( final bool compact = constraints.maxWidth < 760;
crossAxisAlignment: CrossAxisAlignment.start, return Padding(
children: <Widget>[ padding: EdgeInsets.fromLTRB(
Row( compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Expanded( if (compact)
child: Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
@@ -42,110 +48,268 @@ class IdeasView extends ConsumerWidget {
style: Theme.of(context).textTheme.bodyLarge style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary), ?.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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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'),
),
], ],
), ),
), const SizedBox(height: 16),
FilledButton.icon( Expanded(
onPressed: () => _addIdea(context, ref, data.people), child: ListView.separated(
icon: const Icon(Icons.lightbulb_outline_rounded), itemCount: ideas.length,
label: const Text('Add Idea'), separatorBuilder: (_, _) => const SizedBox(height: 12),
), itemBuilder: (BuildContext context, int index) {
], final RelationshipIdea idea = ideas[index];
), final PersonProfile? person = idea.personId == null
const SizedBox(height: 16), ? null
Expanded( : peopleById[idea.personId!];
child: ListView.separated( return FrostedCard(
itemCount: ideas.length, child: compact
separatorBuilder: (_, _) => const SizedBox(height: 12), ? Column(
itemBuilder: (BuildContext context, int index) { crossAxisAlignment: CrossAxisAlignment.start,
final RelationshipIdea idea = ideas[index]; children: <Widget>[
final PersonProfile? person = idea.personId == null Row(
? null children: <Widget>[
: peopleById[idea.personId!]; _IdeaTypeTag(type: idea.type),
return FrostedCard( const SizedBox(width: 8),
child: Row( Expanded(
crossAxisAlignment: CrossAxisAlignment.start, child: Text(
children: <Widget>[ idea.title,
_IdeaTypeTag(type: idea.type), style: Theme.of(context)
const SizedBox(width: 12), .textTheme
Expanded( .titleMedium
child: Column( ?.copyWith(
crossAxisAlignment: CrossAxisAlignment.start, decoration: idea.isArchived
children: <Widget>[ ? TextDecoration
Text( .lineThrough
idea.title, : null,
style: Theme.of(context).textTheme.titleMedium ),
?.copyWith( maxLines: 2,
decoration: idea.isArchived overflow: TextOverflow.ellipsis,
? TextDecoration.lineThrough ),
: null, ),
],
),
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,
),
),
), ),
), const SizedBox(height: 8),
if (idea.details.trim().isNotEmpty) Text(
Padding( person == null
padding: const EdgeInsets.only(top: 6), ? 'Unassigned'
child: Text( : person.name,
idea.details,
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .labelLarge
?.copyWith( ?.copyWith(
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
), ),
), const SizedBox(height: 8),
const SizedBox(height: 8), Wrap(
Text( spacing: 6,
person == null ? 'Unassigned' : person.name, runSpacing: 6,
style: Theme.of(context).textTheme.labelLarge children: <Widget>[
?.copyWith(color: AppTheme.textSecondary), 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: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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: <Widget>[
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()), loading: () => const Center(child: CircularProgressIndicator()),
@@ -293,9 +457,9 @@ class _IdeaEditorDialogState extends State<_IdeaEditorDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(widget.title), title: Text(widget.title),
content: SizedBox( content: SingleChildScrollView(
width: 430, child: ConstrainedBox(
child: SingleChildScrollView( constraints: const BoxConstraints(maxWidth: 430),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
+267 -141
View File
@@ -44,157 +44,283 @@ class _MomentsViewState extends ConsumerState<MomentsView> {
? _selectedPersonId ? _selectedPersonId
: fallbackPersonId; : fallbackPersonId;
return Padding( return LayoutBuilder(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), builder: (BuildContext context, BoxConstraints constraints) {
child: Column( final bool compact = constraints.maxWidth < 760;
crossAxisAlignment: CrossAxisAlignment.start, return Padding(
children: <Widget>[ padding: EdgeInsets.fromLTRB(
Text( compact ? 16 : 28,
'Moments', compact ? 16 : 24,
style: Theme.of(context).textTheme.headlineMedium, compact ? 16 : 28,
compact ? 20 : 28,
), ),
const SizedBox(height: 6), child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
'Capture wins and signals from everyday interactions.', children: <Widget>[
style: Theme.of( Text(
context, 'Moments',
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), style: Theme.of(context).textTheme.headlineMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 6),
FrostedCard( Text(
child: Column( 'Capture wins and signals from everyday interactions.',
children: <Widget>[ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
Row( color: AppTheme.textSecondary,
children: <Widget>[
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<String>(
value: activePerson,
hint: const Text('Person'),
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
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( const SizedBox(height: 16),
padding: const EdgeInsets.only(top: 10), FrostedCard(
child: Text( child: Column(
'Add people first before capturing moments.', crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context).textTheme.bodyMedium children: <Widget>[
?.copyWith(color: AppTheme.textSecondary), 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: 8),
], if (compact)
),
),
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: <Widget>[
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: <Widget>[
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( Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
Text( if (people.isNotEmpty)
'${moment.at.month}/${moment.at.day}', DropdownButtonFormField<String>(
style: Theme.of(context).textTheme.labelLarge initialValue: activePerson,
?.copyWith(color: AppTheme.textSecondary), items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
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: () { else
ref Row(
.read(localRepositoryProvider.notifier) children: <Widget>[
.deleteMoment(moment.id); if (people.isNotEmpty)
}, DropdownButton<String>(
icon: const Icon(Icons.delete_outline_rounded), value: activePerson,
tooltip: 'Delete capture', hint: const Text('Person'),
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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()), loading: () => const Center(child: CircularProgressIndicator()),
+2 -2
View File
@@ -585,8 +585,8 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> {
return AlertDialog( return AlertDialog(
title: Text(widget.title), title: Text(widget.title),
content: SingleChildScrollView( content: SingleChildScrollView(
child: SizedBox( child: ConstrainedBox(
width: 420, constraints: const BoxConstraints(maxWidth: 420),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
+265 -104
View File
@@ -21,15 +21,21 @@ class RemindersView extends ConsumerWidget {
for (final PersonProfile person in data.people) person.id: person, for (final PersonProfile person in data.people) person.id: person,
}; };
return Padding( return LayoutBuilder(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), builder: (BuildContext context, BoxConstraints constraints) {
child: Column( final bool compact = constraints.maxWidth < 760;
crossAxisAlignment: CrossAxisAlignment.start, return Padding(
children: <Widget>[ padding: EdgeInsets.fromLTRB(
Row( compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Expanded( if (compact)
child: Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
@@ -42,90 +48,222 @@ class RemindersView extends ConsumerWidget {
style: Theme.of(context).textTheme.bodyLarge style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary), ?.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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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'),
),
], ],
), ),
), const SizedBox(height: 16),
FilledButton.icon( Expanded(
onPressed: () => _addReminder(context, ref, data.people), child: ListView.separated(
icon: const Icon(Icons.alarm_add_rounded), itemCount: reminders.length,
label: const Text('Add Reminder'), 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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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()), loading: () => const Center(child: CircularProgressIndicator()),
@@ -238,9 +376,9 @@ class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(widget.title), title: Text(widget.title),
content: SizedBox( content: SingleChildScrollView(
width: 420, child: ConstrainedBox(
child: SingleChildScrollView( constraints: const BoxConstraints(maxWidth: 420),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
@@ -291,20 +429,43 @@ class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
decoration: const InputDecoration(labelText: 'Person'), decoration: const InputDecoration(labelText: 'Person'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( LayoutBuilder(
children: <Widget>[ builder: (BuildContext context, BoxConstraints constraints) {
Expanded( final bool compact = constraints.maxWidth < 360;
child: Text( if (compact) {
_formatDateTime(_nextAt), return Column(
style: Theme.of(context).textTheme.bodyLarge, crossAxisAlignment: CrossAxisAlignment.start,
), children: <Widget>[
), Text(
OutlinedButton.icon( _formatDateTime(_nextAt),
onPressed: _pickDateTime, style: Theme.of(context).textTheme.bodyLarge,
icon: const Icon(Icons.schedule_rounded), ),
label: const Text('Pick Time'), const SizedBox(height: 8),
), OutlinedButton.icon(
], onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
}
return Row(
children: <Widget>[
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'),
),
],
);
},
), ),
], ],
), ),
+111 -65
View File
@@ -16,81 +16,106 @@ class SettingsView extends ConsumerWidget {
.asData .asData
?.value; ?.value;
return Padding( return LayoutBuilder(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), builder: (BuildContext context, BoxConstraints constraints) {
child: Column( final bool compact = constraints.maxWidth < 760;
crossAxisAlignment: CrossAxisAlignment.start, return Padding(
children: <Widget>[ padding: EdgeInsets.fromLTRB(
Text('Settings', style: Theme.of(context).textTheme.headlineMedium), compact ? 16 : 28,
const SizedBox(height: 6), compact ? 16 : 24,
Text( compact ? 16 : 28,
'Environment and integration configuration for this build.', compact ? 20 : 28,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
), ),
const SizedBox(height: 16), child: SingleChildScrollView(
FrostedCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
_SettingRow( Text(
label: 'Signed in as', 'Settings',
value: style: Theme.of(context).textTheme.headlineMedium,
session?.user.email ??
session?.user.id ??
'Not signed in',
), ),
const SizedBox(height: 12), const SizedBox(height: 6),
_SettingRow( Text(
label: 'Gateway mode', 'Environment and integration configuration for this build.',
value: AppConfig.useFakeBackend style: Theme.of(context).textTheme.bodyLarge?.copyWith(
? 'Fake (offline-safe)' color: AppTheme.textSecondary,
: 'REST', ),
), ),
const SizedBox(height: 12), const SizedBox(height: 16),
_SettingRow(label: 'Base URL', value: AppConfig.backendBaseUrl), FrostedCard(
const SizedBox(height: 12), child: Column(
const _SettingRow( crossAxisAlignment: CrossAxisAlignment.start,
label: 'Realtime', children: <Widget>[
value: 'Planned later (SSE/WebSocket)', _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: <Widget>[
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), const SizedBox(height: 16),
Row( FrostedCard(
children: <Widget>[ child: Text(
FilledButton.icon( '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',
onPressed: session == null style: Theme.of(context).textTheme.bodyLarge,
? 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,
),
),
],
),
); );
} }
@@ -105,13 +130,34 @@ class SettingsView extends ConsumerWidget {
} }
class _SettingRow extends StatelessWidget { 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 label;
final String value; final String value;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
+166 -113
View File
@@ -14,15 +14,21 @@ class SignalsView extends ConsumerWidget {
signalsControllerProvider, signalsControllerProvider,
); );
return Padding( return LayoutBuilder(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), builder: (BuildContext context, BoxConstraints constraints) {
child: Column( final bool compact = constraints.maxWidth < 760;
crossAxisAlignment: CrossAxisAlignment.start, return Padding(
children: <Widget>[ padding: EdgeInsets.fromLTRB(
Row( compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Expanded( if (compact)
child: Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
@@ -36,123 +42,170 @@ class SignalsView extends ConsumerWidget {
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
), ),
const SizedBox(height: 10),
IconButton.filledTonal(
onPressed: () {
ref.read(signalsControllerProvider.notifier).refresh();
},
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Refresh feed',
),
], ],
), )
), else
IconButton.filledTonal( Row(
onPressed: () { children: <Widget>[
ref.read(signalsControllerProvider.notifier).refresh(); Expanded(
},
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Row(
children: <Widget>[
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( Text(
item.title, 'Signals',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.headlineMedium,
), ),
if (item.description case final String description) const SizedBox(height: 6),
Padding( Text(
padding: const EdgeInsets.only(top: 6), 'Backend-driven recommendations and trends you can act on.',
child: Text( style: Theme.of(context).textTheme.bodyLarge
description, ?.copyWith(color: AppTheme.textSecondary),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
),
const SizedBox(height: 14),
Row(
children: <Widget>[
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'),
),
],
), ),
], ],
), ),
),
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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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(
error: (Object error, StackTrace stackTrace) { child: Text(
return Center( 'Unable to load signals. Try refresh.',
child: Text( style: Theme.of(context).textTheme.bodyLarge,
'Unable to load signals. Try refresh.', ),
style: Theme.of(context).textTheme.bodyLarge, );
), },
); loading: () =>
}, const Center(child: CircularProgressIndicator()),
loading: () => const Center(child: CircularProgressIndicator()), ),
), ),
],
), ),
], );
), },
); );
} }
} }
+97 -38
View File
@@ -23,42 +23,54 @@ class _SyncViewState extends ConsumerState<SyncView> {
syncQueueRepositoryProvider, syncQueueRepositoryProvider,
); );
return Padding( return LayoutBuilder(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), builder: (BuildContext context, BoxConstraints constraints) {
child: Column( final bool compact = constraints.maxWidth < 760;
crossAxisAlignment: CrossAxisAlignment.start, return Padding(
children: <Widget>[ padding: EdgeInsets.fromLTRB(
Text('Sync', style: Theme.of(context).textTheme.headlineMedium), compact ? 16 : 28,
const SizedBox(height: 6), compact ? 16 : 24,
Text( compact ? 16 : 28,
'Queue local changes, then push and pull when network is available.', compact ? 20 : 28,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
), ),
const SizedBox(height: 16), child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
child: syncState.when( children: <Widget>[
data: (SyncState state) => _buildBody(context, state), Text('Sync', style: Theme.of(context).textTheme.headlineMedium),
loading: () => const Center(child: CircularProgressIndicator()), const SizedBox(height: 6),
error: (Object error, StackTrace stackTrace) { Text(
return Center( 'Queue local changes, then push and pull when network is available.',
child: Text( style: Theme.of(
'Unable to load sync state. $error', context,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
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( return ListView(
children: <Widget>[ children: <Widget>[
FrostedCard( FrostedCard(
@@ -111,12 +123,36 @@ class _SyncViewState extends ConsumerState<SyncView> {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_infoRow('Pending changes', '${state.pendingChanges.length}'), _infoRow(
_infoRow('Cursor', state.cursor ?? 'not set'), label: 'Pending changes',
_infoRow('Last sync', _formatDateTime(state.lastSyncAt)), value: '${state.pendingChanges.length}',
_infoRow('Last attempt', _formatDateTime(state.lastAttemptAt)), compact: compact,
_infoRow('Last rejected count', '${state.lastRejected.length}'), ),
_infoRow('Last error', state.lastError ?? 'none'), _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<SyncView> {
); );
} }
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: <Widget>[
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( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 2),
child: Row( child: Row(