首次远程推送

This commit is contained in:
2026-09-27 23:40:22 +08:00
parent 2e0bf3c784
commit 8afa47952a
70 changed files with 8603 additions and 4502 deletions
+452 -148
View File
@@ -1,122 +1,262 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.dart';
import 'app_bottom_sheet.dart';
import 'app_busy_indicator.dart';
import 'surface_panel.dart';
Future<void> showHabitDialog(
BuildContext context,
ValueChanged<String> onSubmit, {
Future<bool> Function(String value) onSubmit, {
String initialValue = '',
}) async {
var value = initialValue;
await showDialog<void>(
final l10n = context.l10n;
await showTextEntrySheet(
context: context,
builder:
(context) => AlertDialog(
title: Text(
initialValue.isEmpty
? context.l10n.addHabitTitle
: context.l10n.renameHabitTitle,
),
content: TextFormField(
initialValue: initialValue,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: context.l10n.habitHint,
),
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(value);
Navigator.pop(context);
},
child: Text(context.l10n.save),
),
],
),
title: initialValue.isEmpty ? l10n.addHabitTitle : l10n.renameHabitTitle,
hintText: l10n.habitHint,
initialValue: initialValue,
icon: Icons.add_task_rounded,
accentBuilder: (context) => FocusSeedVisuals.of(context).habit,
onSubmit: onSubmit,
);
}
Future<void> showIntentionDialog(
BuildContext context,
ValueChanged<String> onSubmit, {
Future<bool> Function(String value) onSubmit, {
String initialValue = '',
}) async {
var value = initialValue;
await showDialog<void>(
final l10n = context.l10n;
await showTextEntrySheet(
context: context,
title:
initialValue.isEmpty ? l10n.setIntentionTitle : l10n.editIntentionTitle,
hintText: l10n.intentionHint,
initialValue: initialValue,
icon: Icons.spa_rounded,
maxLength: 90,
maxLines: 3,
accentBuilder: (context) => FocusSeedVisuals.of(context).action,
onSubmit: onSubmit,
);
}
Future<void> showTextEntrySheet({
required BuildContext context,
required String title,
required String hintText,
required Future<bool> Function(String value) onSubmit,
required IconData icon,
required Color Function(BuildContext context) accentBuilder,
String initialValue = '',
int? maxLength,
int minLines = 1,
int maxLines = 1,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => AlertDialog(
title: Text(
initialValue.isEmpty
? context.l10n.setIntentionTitle
: context.l10n.editIntentionTitle,
),
content: TextFormField(
initialValue: initialValue,
autofocus: true,
maxLength: 90,
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: context.l10n.intentionHint),
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(value);
Navigator.pop(context);
},
child: Text(context.l10n.save),
),
],
(context) => _TextEntrySheet(
title: title,
hintText: hintText,
initialValue: initialValue,
maxLength: maxLength,
minLines: minLines,
maxLines: maxLines,
icon: icon,
accent: accentBuilder(context),
onSubmit: onSubmit,
),
);
}
class _TextEntrySheet extends StatefulWidget {
const _TextEntrySheet({
required this.title,
required this.hintText,
required this.initialValue,
required this.icon,
required this.accent,
required this.onSubmit,
this.maxLength,
this.minLines = 1,
this.maxLines = 1,
});
final String title;
final String hintText;
final String initialValue;
final IconData icon;
final Color accent;
final Future<bool> Function(String value) onSubmit;
final int? maxLength;
final int minLines;
final int maxLines;
@override
State<_TextEntrySheet> createState() => _TextEntrySheetState();
}
class _TextEntrySheetState extends State<_TextEntrySheet> {
late final TextEditingController _controller;
bool _saving = false;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_saving) {
return;
}
final value = _controller.text.trim();
if (value.isEmpty) {
setState(() => _errorText = context.l10n.textEntryRequired);
return;
}
setState(() {
_saving = true;
_errorText = null;
});
final saved = await widget.onSubmit(value);
if (!mounted) {
return;
}
if (saved) {
Navigator.pop(context);
return;
}
setState(() {
_saving = false;
_errorText = context.l10n.saveFailed;
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final foreground =
ThemeData.estimateBrightnessForColor(widget.accent) == Brightness.dark
? Colors.white
: scheme.onSurface;
return AppBottomSheetScaffold(
title: widget.title,
icon: widget.icon,
accent: widget.accent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SurfacePanel(
radius: 18,
tone: SurfacePanelTone.paper,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
controller: _controller,
autofocus: true,
maxLength: widget.maxLength,
minLines: widget.minLines,
maxLines: widget.maxLines,
textCapitalization: TextCapitalization.sentences,
textInputAction:
widget.maxLines == 1
? TextInputAction.done
: TextInputAction.newline,
onChanged: (_) {
if (_errorText == null) {
setState(() {});
return;
}
setState(() => _errorText = null);
},
onSubmitted: (_) {
if (widget.maxLines == 1) {
unawaited(_submit());
}
},
decoration: InputDecoration(
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hintText,
errorText: _errorText,
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
),
const SizedBox(width: 10),
Expanded(
flex: 2,
child: FilledButton(
onPressed:
_saving || _controller.text.trim().isEmpty
? null
: _submit,
style: FilledButton.styleFrom(
backgroundColor: widget.accent,
foregroundColor: foreground,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
textStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
child:
_saving
? AppBusyIndicator(
semanticLabel: l10n.savingLabel,
color: foreground,
)
: Text(l10n.save),
),
),
],
),
],
),
);
}
}
Future<void> confirmDeleteHabit(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
await showConfirmSheet(
context: context,
builder:
(context) => AlertDialog(
title: Text(context.l10n.deleteHabitTitle),
content: Text(context.l10n.deleteHabitBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.delete),
),
],
),
title: context.l10n.deleteHabitTitle,
body: context.l10n.deleteHabitBody,
confirmLabel: context.l10n.delete,
icon: Icons.delete_outline_rounded,
accentBuilder: (context) => Theme.of(context).colorScheme.error,
onConfirm: onConfirm,
);
}
@@ -124,26 +264,14 @@ Future<void> confirmClearToday(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
await showConfirmSheet(
context: context,
builder:
(context) => AlertDialog(
title: Text(context.l10n.clearTodayDataTitle),
content: Text(context.l10n.clearTodayDataBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.clearToday),
),
],
),
title: context.l10n.clearTodayDataTitle,
body: context.l10n.clearTodayDataBody,
confirmLabel: context.l10n.clearToday,
icon: Icons.refresh_rounded,
accentBuilder: (context) => FocusSeedVisuals.of(context).warning,
onConfirm: onConfirm,
);
}
@@ -151,60 +279,236 @@ Future<void> confirmClearData(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
await showConfirmSheet(
context: context,
title: context.l10n.clearAllDataTitle,
body: context.l10n.clearAllDataBody,
confirmLabel: context.l10n.deleteAll,
icon: Icons.delete_forever_rounded,
accentBuilder: (context) => Theme.of(context).colorScheme.error,
onConfirm: onConfirm,
);
}
Future<void> showConfirmSheet({
required BuildContext context,
required String title,
required String body,
required String confirmLabel,
required IconData icon,
required Color Function(BuildContext context) accentBuilder,
required VoidCallback onConfirm,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => AlertDialog(
title: Text(context.l10n.clearAllDataTitle),
content: Text(context.l10n.clearAllDataBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.deleteAll),
),
],
(context) => _ConfirmSheet(
title: title,
body: body,
confirmLabel: confirmLabel,
icon: icon,
accent: accentBuilder(context),
onConfirm: onConfirm,
),
);
}
class _ConfirmSheet extends StatelessWidget {
const _ConfirmSheet({
required this.title,
required this.body,
required this.confirmLabel,
required this.icon,
required this.accent,
required this.onConfirm,
});
final String title;
final String body;
final String confirmLabel;
final IconData icon;
final Color accent;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return AppBottomSheetScaffold(
title: title,
icon: icon,
accent: accent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SurfacePanel(
radius: 18,
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
color: Color.alphaBlend(
accent.withValues(alpha: 0.045),
scheme.surfaceContainerLowest,
),
child: Text(
body,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
height: 1.42,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 14),
_ConfirmActions(
accent: accent,
confirmLabel: confirmLabel,
onConfirm: onConfirm,
),
],
),
);
}
}
void showPrivacySheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: ListView(
shrinkWrap: true,
(context) => AppBottomSheetScaffold(
title: context.l10n.privacyTitle,
subtitle: context.l10n.privacySubtitle,
icon: Icons.lock_outline_rounded,
accent: FocusSeedVisuals.of(context).privacy,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.privacyTitle,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
),
_PrivacyPoint(
icon: Icons.phone_android_rounded,
text: context.l10n.privacyBodyOne,
),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyOne),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyTwo),
_PrivacyPoint(
icon: Icons.cleaning_services_rounded,
text: context.l10n.privacyBodyTwo,
),
],
),
),
);
}
void showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
class _PrivacyPoint extends StatelessWidget {
const _PrivacyPoint({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final visuals = FocusSeedVisuals.of(context);
return SurfacePanel(
radius: 18,
padding: const EdgeInsets.fromLTRB(13, 13, 13, 13),
color: Color.alphaBlend(
visuals.privacy.withValues(alpha: 0.045),
scheme.surfaceContainerLowest,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: visuals.privacy, size: 21),
const SizedBox(width: 11),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
height: 1.42,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
void showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 92),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
);
}
class _ConfirmActions extends StatelessWidget {
const _ConfirmActions({
required this.accent,
required this.confirmLabel,
required this.onConfirm,
});
final Color accent;
final String confirmLabel;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final foreground =
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
? Colors.white
: scheme.onSurface;
final confirmButton = FilledButton(
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: foreground,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(confirmLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
);
final cancelButton = TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
);
return LayoutBuilder(
builder: (context, constraints) {
final textScale = MediaQuery.textScalerOf(context).scale(1);
if (constraints.maxWidth < 330 || textScale > 1.25) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [confirmButton, const SizedBox(height: 8), cancelButton],
);
}
return Row(
children: [
Expanded(child: cancelButton),
const SizedBox(width: 10),
Expanded(flex: 2, child: confirmButton),
],
);
},
);
}
}