Refine views for mobile-first and responsive layouts
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -45,107 +45,141 @@ class _SignInViewState extends ConsumerState<SignInView> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: FrostedCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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<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(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 480;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
|
||||
String _labelForMethod(SignInMethod method) {
|
||||
return switch (method) {
|
||||
SignInMethod.password => 'Password',
|
||||
SignInMethod.emailMagicLink => 'Magic Link',
|
||||
SignInMethod.oidc => 'OIDC',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,71 +19,89 @@ class DashboardView extends ConsumerWidget {
|
||||
final DashboardSummary summary = data.summary();
|
||||
final List<DashboardTask> tasks = data.tasks;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 36),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
_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: <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(
|
||||
label: 'Weekly Rhythm',
|
||||
value: '${summary.weeklyConsistency}%',
|
||||
accent: const Color(0xFF30B66A),
|
||||
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,
|
||||
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()),
|
||||
@@ -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: <Widget>[
|
||||
@@ -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) ...<Widget>[
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_shortDate(task.when),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+268
-104
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
if (compact)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: <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,
|
||||
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: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_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: <Widget>[
|
||||
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()),
|
||||
@@ -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: <Widget>[
|
||||
|
||||
@@ -44,157 +44,283 @@ class _MomentsViewState extends ConsumerState<MomentsView> {
|
||||
? _selectedPersonId
|
||||
: fallbackPersonId;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
Row(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <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),
|
||||
const SizedBox(height: 8),
|
||||
if (compact)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${moment.at.month}/${moment.at.day}',
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(color: AppTheme.textSecondary),
|
||||
if (people.isNotEmpty)
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: activePerson,
|
||||
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: () {
|
||||
ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.deleteMoment(moment.id);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
tooltip: 'Delete capture',
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: <Widget>[
|
||||
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(
|
||||
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()),
|
||||
|
||||
@@ -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: <Widget>[
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
if (compact)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: <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()),
|
||||
@@ -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: <Widget>[
|
||||
@@ -291,20 +429,43 @@ class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
|
||||
decoration: const InputDecoration(labelText: 'Person'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 360;
|
||||
if (compact) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
_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: <Widget>[
|
||||
_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),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
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: <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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
if (compact)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
Expanded(
|
||||
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,
|
||||
'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: <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'),
|
||||
),
|
||||
],
|
||||
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: <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(
|
||||
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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,42 +23,54 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
||||
syncQueueRepositoryProvider,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
FrostedCard(
|
||||
@@ -111,12 +123,36 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
||||
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<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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
|
||||
Reference in New Issue
Block a user