diff --git a/.gitignore b/.gitignore index 4c5c9e9..636e697 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ app.*.map.json lib/services/local_config.dart *.env server/goworkbro-server.exe + +# Python cache +__pycache__/ +*.pyc diff --git a/assets/fonts/LXGWWenKai-Regular.ttf b/assets/fonts/LXGWWenKai-Regular.ttf new file mode 100644 index 0000000..de26379 Binary files /dev/null and b/assets/fonts/LXGWWenKai-Regular.ttf differ diff --git a/assets/icons/app_icon.ico b/assets/icons/app_icon.ico new file mode 100644 index 0000000..5dcefae Binary files /dev/null and b/assets/icons/app_icon.ico differ diff --git a/assets/icons/app_icon.png b/assets/icons/app_icon.png new file mode 100644 index 0000000..13a5f26 Binary files /dev/null and b/assets/icons/app_icon.png differ diff --git a/assets/icons/tray_icon.ico b/assets/icons/tray_icon.ico new file mode 100644 index 0000000..7192ae7 Binary files /dev/null and b/assets/icons/tray_icon.ico differ diff --git a/lib/main.dart b/lib/main.dart index 3bbd6cc..a516ac8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,9 @@ import 'package:provider/provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:window_manager/window_manager.dart'; import 'providers/app_provider.dart'; +import 'services/app_locale.dart'; import 'services/supabase_config.dart'; +import 'services/tray_service.dart'; import 'theme/app_theme.dart'; import 'screens/auth_screen.dart'; import 'screens/todo_screen.dart'; @@ -15,6 +17,7 @@ import 'screens/me_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Must be initialized before window_manager on Windows if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) { await windowManager.ensureInitialized(); await windowManager.waitUntilReadyToShow( @@ -30,13 +33,17 @@ void main() async { await windowManager.focus(); }, ); + // Prevent default close — we hide to tray instead + await windowManager.setPreventClose(true); + // Initialize system tray + await TrayService().init(); } // Initialize Supabase before running app (if configured) if (isSupabaseConfigured) { await Supabase.initialize( url: supabaseUrl, - anonKey: supabaseAnonKey, + publishableKey: supabaseAnonKey, debug: kDebugMode, ); } @@ -49,15 +56,28 @@ class GoWorkBroApp extends StatelessWidget { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => AppProvider(), - child: MaterialApp( - title: 'GoWorkBro', - debugShowCheckedModeBanner: false, - theme: AppTheme.light, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.system, - home: const AuthGate(), + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => AppLocaleProvider(), + ), + ChangeNotifierProvider( + create: (_) => AppProvider(), + ), + ], + child: Consumer( + builder: (context, localeProvider, _) { + return MaterialApp( + title: 'GoWorkBro', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: localeProvider.themeMode, + locale: localeProvider.flutterLocale, + supportedLocales: const [Locale('zh'), Locale('en')], + home: const AuthGate(), + ); + }, ), ); } @@ -145,9 +165,27 @@ class AppShell extends StatefulWidget { State createState() => _AppShellState(); } -class _AppShellState extends State { +class _AppShellState extends State with WindowListener { int _currentIndex = 0; + @override + void initState() { + super.initState(); + windowManager.addListener(this); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + /// Intercept the window close button — hide to tray instead of quitting. + @override + void onWindowClose() async { + await windowManager.hide(); + } + final _screens = const [ TodoScreen(), CountdownScreen(), diff --git a/lib/models/sleep_record.dart b/lib/models/sleep_record.dart index 8c815c6..425bf0f 100644 --- a/lib/models/sleep_record.dart +++ b/lib/models/sleep_record.dart @@ -7,6 +7,7 @@ class SleepRecord { final String recordDate; final String? wakeTime; final String? sleepTime; + final String? workoutTime; final String? note; SleepRecord({ @@ -14,6 +15,7 @@ class SleepRecord { required this.recordDate, this.wakeTime, this.sleepTime, + this.workoutTime, this.note, }); @@ -21,6 +23,7 @@ class SleepRecord { required String recordDate, String? wakeTime, String? sleepTime, + String? workoutTime, String? note, }) { return SleepRecord( @@ -28,6 +31,7 @@ class SleepRecord { recordDate: recordDate, wakeTime: wakeTime, sleepTime: sleepTime, + workoutTime: workoutTime, note: note, ); } @@ -35,6 +39,7 @@ class SleepRecord { SleepRecord copyWith({ String? wakeTime, String? sleepTime, + String? workoutTime, String? note, }) { return SleepRecord( @@ -42,6 +47,7 @@ class SleepRecord { recordDate: recordDate, wakeTime: wakeTime ?? this.wakeTime, sleepTime: sleepTime ?? this.sleepTime, + workoutTime: workoutTime ?? this.workoutTime, note: note ?? this.note, ); } @@ -51,6 +57,7 @@ class SleepRecord { 'record_date': recordDate, 'wake_time': wakeTime, 'sleep_time': sleepTime, + 'workout_time': workoutTime, 'note': note, }; @@ -59,6 +66,7 @@ class SleepRecord { recordDate: m['record_date'] as String, wakeTime: m['wake_time'] as String?, sleepTime: m['sleep_time'] as String?, + workoutTime: m['workout_time'] as String?, note: m['note'] as String?, ); } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f6d5959..df1d6f4 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,11 +1,14 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../models/models.dart'; import '../services/database_service.dart'; -import '../services/api_service.dart'; +import '../services/app_locale.dart'; import '../services/sync_service.dart'; import '../services/supabase_config.dart'; +const _uuid = Uuid(); + /// Central app state — manages all data and daily rollover logic class AppProvider extends ChangeNotifier { List _todos = []; @@ -16,8 +19,8 @@ class AppProvider extends ChangeNotifier { List _todaySessions = []; List _sleepRecords = []; String _userName = 'AzarAI'; - String _apiBaseUrl = 'http://localhost:8765'; - bool _apiConnected = false; + AppLocale _locale = AppLocale.zh; + ThemeMode _themeMode = ThemeMode.system; String _lastRolloverDate = ''; Timer? _rolloverTimer; @@ -27,8 +30,11 @@ class AppProvider extends ChangeNotifier { List get todaySessions => _todaySessions; List get sleepRecords => _sleepRecords; String get userName => _userName; - String get apiBaseUrl => _apiBaseUrl; - bool get apiConnected => _apiConnected; + AppLocale get locale => _locale; + ThemeMode get themeMode => _themeMode; + Locale get flutterLocale => _locale == AppLocale.zh + ? const Locale('zh') + : const Locale('en'); String get todayDate { final now = DateTime.now(); @@ -37,8 +43,26 @@ class AppProvider extends ChangeNotifier { Future init() async { _userName = await DatabaseService.getSetting('user_name') ?? 'AzarAI'; - _apiBaseUrl = await DatabaseService.getSetting('api_base_url') ?? - 'http://localhost:8765'; + + // Load persisted locale and theme mode + final savedLocale = await DatabaseService.getSetting('locale'); + if (savedLocale == 'en') { + _locale = AppLocale.en; + } else { + _locale = AppLocale.zh; + } + final savedTheme = await DatabaseService.getSetting('theme_mode'); + switch (savedTheme) { + case 'light': + _themeMode = ThemeMode.light; + break; + case 'dark': + _themeMode = ThemeMode.dark; + break; + default: + _themeMode = ThemeMode.system; + } + _lastRolloverDate = await DatabaseService.getSetting('last_rollover_date') ?? ''; @@ -57,14 +81,15 @@ class AppProvider extends ChangeNotifier { } } - _apiConnected = await ApiService.testConnection(); - // Check every minute if the date has changed (for cross-day rollover) _rolloverTimer = Timer.periodic(const Duration(minutes: 1), (_) { if (_lastRolloverDate != todayDate) { _performDailyRollover().then((_) => refreshAll()); } }); + + _isInitialized = true; + notifyListeners(); } @override @@ -116,6 +141,24 @@ class AppProvider extends ChangeNotifier { completedDate: !todo.isCompleted ? DateTime.now().toIso8601String() : null, ); await DatabaseService.updateTodo(updated); + + // "明天继续": when marking complete and the todo wants to repeat tomorrow, + // auto-recreate an incomplete copy for the next day. + if (!todo.isCompleted && todo.keepTomorrow) { + final newTodo = Todo( + id: _uuid.v4(), + title: todo.title, + timingType: todo.timingType, + durationMinutes: todo.durationMinutes, + isCompleted: false, + sortOrder: todo.sortOrder, + keepTomorrow: true, + createdDate: DateTime.now().toIso8601String(), + ); + await DatabaseService.insertTodo(newTodo); + SyncService.pushTodo(newTodo); + } + _todos = await DatabaseService.getTodos(); notifyListeners(); SyncService.pushTodo(updated); @@ -247,10 +290,39 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } - Future setApiBaseUrl(String url) async { - _apiBaseUrl = url; - await ApiService.setBaseUrl(url); - _apiConnected = await ApiService.testConnection(); + Future setLocale(AppLocale locale) async { + _locale = locale; + await DatabaseService.setSetting('locale', locale == AppLocale.en ? 'en' : 'zh'); + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + _themeMode = mode; + String value; + switch (mode) { + case ThemeMode.light: + value = 'light'; + break; + case ThemeMode.dark: + value = 'dark'; + break; + case ThemeMode.system: + value = 'system'; + break; + } + await DatabaseService.setSetting('theme_mode', value); + notifyListeners(); + } + + /// Delete all local data and reset in-memory state. + Future deleteAllData() async { + await DatabaseService.deleteAllData(); + _todos = []; + _habits = []; + _countdowns = []; + _todaySessions = []; + _sleepRecords = []; + _lastRolloverDate = ''; notifyListeners(); } diff --git a/lib/screens/me_screen.dart b/lib/screens/me_screen.dart index 995d74d..bd860ba 100644 --- a/lib/screens/me_screen.dart +++ b/lib/screens/me_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/models.dart'; import '../providers/app_provider.dart'; +import '../services/app_locale.dart'; import '../services/sync_service.dart'; import '../services/update_service.dart'; import '../theme/app_theme.dart'; @@ -118,7 +119,7 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin borderRadius: BorderRadius.circular(12), ), child: Text( - 'ID: AzarAI', + 'ID: ${provider.userName == 'AzarAI' ? 'AzarAI' : provider.userName}', style: TextStyle( fontSize: 12, color: theme.colorScheme.primary, @@ -139,11 +140,14 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin Widget _buildSleepTab(BuildContext context, AppProvider provider, ThemeData theme) { final today = provider.todayDate; final todayRecord = provider.sleepRecords.where((r) => r.recordDate == today).toList(); + final wakeTime = todayRecord.isNotEmpty ? todayRecord.first.wakeTime : null; + final workoutTime = todayRecord.isNotEmpty ? todayRecord.first.workoutTime : null; + final sleepTime = todayRecord.isNotEmpty ? todayRecord.first.sleepTime : null; return ListView( padding: const EdgeInsets.all(16), children: [ - // Today's check-in card + // Today's check-in card — 3 buttons in a row Card( child: Padding( padding: const EdgeInsets.all(20), @@ -160,18 +164,29 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin theme, label: '起床', icon: Icons.wb_sunny_outlined, - time: todayRecord.isNotEmpty ? todayRecord.first.wakeTime : null, + time: wakeTime, onTap: () => _recordTime(context, provider, 'wake'), ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), + Expanded( + child: _buildCheckInButton( + context, + theme, + label: '健身', + icon: Icons.fitness_center_outlined, + time: workoutTime, + onTap: () => _recordTime(context, provider, 'workout'), + ), + ), + const SizedBox(width: 8), Expanded( child: _buildCheckInButton( context, theme, label: '睡觉', icon: Icons.bedtime_outlined, - time: todayRecord.isNotEmpty ? todayRecord.first.sleepTime : null, + time: sleepTime, onTap: () => _recordTime(context, provider, 'sleep'), ), ), @@ -213,7 +228,7 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin onTap: onTap, borderRadius: BorderRadius.circular(16), child: Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 8), decoration: BoxDecoration( color: hasRecord ? theme.colorScheme.primary.withValues(alpha: 0.08) @@ -225,22 +240,22 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin : Colors.transparent, ), ), - child: Column( - children: [ - Icon(icon, size: 32, color: hasRecord ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant), - const SizedBox(height: 8), - Text(label, style: theme.textTheme.bodyMedium), - const SizedBox(height: 4), - Text( - hasRecord ? _formatTime(time) : '未打卡', - style: TextStyle( - fontSize: 13, - fontWeight: hasRecord ? FontWeight.w600 : FontWeight.normal, - color: hasRecord ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant, + child: Column( + children: [ + Icon(icon, size: 28, color: hasRecord ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 8), + Text(label, style: theme.textTheme.bodyMedium), + const SizedBox(height: 4), + Text( + hasRecord ? _formatTime(time) : '未打卡', + style: TextStyle( + fontSize: 12, + fontWeight: hasRecord ? FontWeight.w600 : FontWeight.normal, + color: hasRecord ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant, + ), ), - ), - ], - ), + ], + ), ), ), ); @@ -261,7 +276,7 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin ), title: Text(record.recordDate, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), subtitle: Text( - '起床 ${_formatTime(record.wakeTime)} · 睡觉 ${_formatTime(record.sleepTime)}', + '起床 ${_formatTime(record.wakeTime)} · 健身 ${_formatTime(record.workoutTime)} · 睡觉 ${_formatTime(record.sleepTime)}', style: const TextStyle(fontSize: 12), ), ), @@ -270,10 +285,16 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin void _recordTime(BuildContext context, AppProvider provider, String type) async { final now = TimeOfDay.now(); + final helpText = switch (type) { + 'wake' => '选择起床时间', + 'workout' => '选择健身时间', + 'sleep' => '选择睡觉时间', + _ => '选择时间', + }; final picked = await showTimePicker( context: context, initialTime: now, - helpText: type == 'wake' ? '选择起床时间' : '选择睡觉时间', + helpText: helpText, ); if (picked == null) return; @@ -283,13 +304,24 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin SleepRecord record; if (existing.isNotEmpty) { - record = type == 'wake' - ? existing.first.copyWith(wakeTime: timeStr) - : existing.first.copyWith(sleepTime: timeStr); + switch (type) { + case 'wake': + record = existing.first.copyWith(wakeTime: timeStr); + break; + case 'workout': + record = existing.first.copyWith(workoutTime: timeStr); + break; + case 'sleep': + record = existing.first.copyWith(sleepTime: timeStr); + break; + default: + record = existing.first; + } } else { record = SleepRecord.create( recordDate: today, wakeTime: type == 'wake' ? timeStr : null, + workoutTime: type == 'workout' ? timeStr : null, sleepTime: type == 'sleep' ? timeStr : null, ); } @@ -305,8 +337,25 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin context: context, builder: (context) => AlertDialog( title: const Text('更换头像'), - content: const Text('头像功能开发中,敬请期待 🚀'), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('好的'))], + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + ), + child: Icon(Icons.cloud_upload_outlined, size: 36, color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 16), + const Text('头像上传功能开发中,敬请期待 🚀'), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('好的')), + ], ), ); } @@ -431,7 +480,7 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin return ListView( padding: const EdgeInsets.all(16), children: [ - // Cloud sync status + // ---- Language selector ---- Card( child: Padding( padding: const EdgeInsets.all(20), @@ -440,43 +489,57 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin children: [ Row( children: [ - Icon(Icons.cloud_done_outlined, size: 20, - color: SyncService.isInitialized ? Colors.green : Colors.grey), + Icon(Icons.language, size: 20, color: theme.colorScheme.primary), const SizedBox(width: 8), - Text('云端同步', style: theme.textTheme.titleMedium), + Text('语言', style: theme.textTheme.titleMedium), ], ), const SizedBox(height: 12), + SegmentedButton( + segments: const [ + ButtonSegment(value: AppLocale.zh, label: Text('中文')), + ButtonSegment(value: AppLocale.en, label: Text('English')), + ], + selected: {provider.locale}, + onSelectionChanged: (set) => provider.setLocale(set.first), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // ---- Theme mode selector ---- + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: SyncService.isInitialized ? Colors.green : Colors.grey, - ), - ), + Icon(Icons.palette_outlined, size: 20, color: theme.colorScheme.primary), const SizedBox(width: 8), - Text( - SyncService.isInitialized ? '已连接 Supabase' : '未连接', - style: theme.textTheme.bodyMedium, - ), + Text('主题', style: theme.textTheme.titleMedium), ], ), - if (SyncService.isInitialized) ...[ - const SizedBox(height: 8), - Text( - '用户: ${Supabase.instance.client.auth.currentUser?.email ?? "未知"}', - style: theme.textTheme.bodySmall, - ), - ], + const SizedBox(height: 12), + SegmentedButton( + segments: const [ + ButtonSegment(value: ThemeMode.light, label: Text('浅色')), + ButtonSegment(value: ThemeMode.dark, label: Text('深色')), + ButtonSegment(value: ThemeMode.system, label: Text('跟随系统')), + ], + selected: {provider.themeMode}, + onSelectionChanged: (set) => provider.setThemeMode(set.first), + ), ], ), ), ), const SizedBox(height: 16), - // Local API connection status + + // ---- Cloud sync status ---- Card( child: Padding( padding: const EdgeInsets.all(20), @@ -485,9 +548,10 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin children: [ Row( children: [ - Icon(Icons.dns_outlined, size: 20, color: theme.colorScheme.primary), + Icon(Icons.cloud_done_outlined, size: 20, + color: SyncService.isInitialized ? Colors.green : Colors.grey), const SizedBox(width: 8), - Text('本地后端', style: theme.textTheme.titleMedium), + Text('云端同步', style: theme.textTheme.titleMedium), ], ), const SizedBox(height: 12), @@ -498,133 +562,146 @@ class _MeScreenState extends State with SingleTickerProviderStateMixin height: 8, decoration: BoxDecoration( shape: BoxShape.circle, - color: provider.apiConnected ? Colors.green : Colors.grey, + color: SyncService.isInitialized ? Colors.green : Colors.grey, ), ), const SizedBox(width: 8), Text( - provider.apiConnected ? '已连接' : '未连接', + SyncService.isInitialized ? '已连接 Supabase' : '未连接', style: theme.textTheme.bodyMedium, ), ], ), - const SizedBox(height: 8), - Text('API 地址: ${provider.apiBaseUrl}', style: theme.textTheme.bodySmall), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _showApiUrlDialog(context, provider), - icon: const Icon(Icons.edit, size: 18), - label: const Text('修改 API 地址'), - ), + if (SyncService.isInitialized) ...[ + const SizedBox(height: 8), + Text( + '用户: ${Supabase.instance.client.auth.currentUser?.email ?? "未知"}', + style: theme.textTheme.bodySmall, + ), + ], ], ), ), ), const SizedBox(height: 16), - // Logout + + // ---- Check for updates ---- Card( child: ListTile( - leading: const Icon(Icons.logout, color: Colors.redAccent), - title: const Text('退出登录', style: TextStyle(color: Colors.redAccent)), - subtitle: const Text('退出后数据保留在本地,重新登录可同步'), - trailing: const Icon(Icons.chevron_right, color: Colors.redAccent), - onTap: () => _showLogoutConfirm(context), + leading: const Icon(Icons.system_update), + title: const Text('检查更新'), + subtitle: const Text('检查是否有新版本可用'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _checkForUpdate(context), ), ), const SizedBox(height: 16), - // Check for updates + + // ---- About (merged with licenses) ---- Card( child: ListTile( - leading: const Icon(Icons.system_update), - title: const Text('检查更新'), - subtitle: const Text('检查是否有新版本可用'), + leading: const Icon(Icons.info_outline), + title: const Text('关于 GoWorkBro'), + subtitle: const Text('版本 1.0.0'), trailing: const Icon(Icons.chevron_right), - onTap: () => _checkForUpdate(context), + onTap: () { + showAboutDialog( + context: context, + applicationName: 'GoWorkBro', + applicationVersion: '1.0.0', + applicationLegalese: '© 2026 AzarAI', + applicationIcon: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.check_circle, color: Colors.white, size: 28), + ), + ); + }, ), ), const SizedBox(height: 16), - // App info + + // ---- Logout ---- Card( - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('关于 GoWorkBro'), - subtitle: const Text('版本 0.1.0'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - showAboutDialog( - context: context, - applicationName: 'GoWorkBro', - applicationVersion: '1.0.0', - applicationLegalese: '© 2026 AzarAI', - ); - }, - ), - const Divider(height: 1, indent: 16), - ListTile( - leading: const Icon(Icons.code), - title: const Text('开源许可'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - showLicensePage( - context: context, - applicationName: 'GoWorkBro', - applicationVersion: '1.0.0', - ); - }, + child: ListTile( + leading: const Icon(Icons.logout, color: Colors.redAccent), + title: const Text('退出登录', style: TextStyle(color: Colors.redAccent)), + subtitle: const Text('退出后数据保留在本地,重新登录可同步'), + trailing: const Icon(Icons.chevron_right, color: Colors.redAccent), + onTap: () => _showLogoutConfirm(context), + ), + ), + const SizedBox(height: 16), + + // ---- Delete all data (red, bottom) ---- + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _showDeleteDataConfirm(context, provider), + icon: const Icon(Icons.delete_forever, color: Colors.redAccent), + label: const Text('删除所有数据', style: TextStyle(color: Colors.redAccent)), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - ], + ), ), ), + const SizedBox(height: 24), ], ); } - void _showApiUrlDialog(BuildContext context, AppProvider provider) { - final controller = TextEditingController(text: provider.apiBaseUrl); + void _showLogoutConfirm(BuildContext context) { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('API 地址'), - content: TextField( - controller: controller, - decoration: const InputDecoration(hintText: 'http://localhost:8765'), - autofocus: true, - ), + title: const Text('退出登录'), + content: const Text('确定退出登录?本地数据会保留,重新登录后将从云端同步。'), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), TextButton( onPressed: () { - provider.setApiBaseUrl(controller.text.trim()); Navigator.pop(context); + Supabase.instance.client.auth.signOut(); }, - child: const Text('保存'), + style: TextButton.styleFrom(foregroundColor: Colors.redAccent), + child: const Text('退出'), ), ], ), ); } - void _showLogoutConfirm(BuildContext context) { - showDialog( + void _showDeleteDataConfirm(BuildContext context, AppProvider provider) async { + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('退出登录'), - content: const Text('确定退出登录?本地数据会保留,重新登录后将从云端同步。'), + title: const Text('删除所有数据'), + content: const Text('确定删除所有本地数据?此操作不可撤销,包括待办、习惯、打卡记录等。'), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')), TextButton( - onPressed: () { - Navigator.pop(context); - Supabase.instance.client.auth.signOut(); - }, + onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom(foregroundColor: Colors.redAccent), - child: const Text('退出'), + child: const Text('删除'), ), ], ), ); + if (confirmed != true || !mounted) return; + await provider.deleteAllData(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('所有数据已删除')), + ); } Future _checkForUpdate(BuildContext context) async { diff --git a/lib/screens/timer_screen.dart b/lib/screens/timer_screen.dart index 4ad3518..4770155 100644 --- a/lib/screens/timer_screen.dart +++ b/lib/screens/timer_screen.dart @@ -6,7 +6,7 @@ import '../providers/app_provider.dart'; /// Full-screen pomodoro timer page. /// Supports forward (count up), backward (count down), and none (no timing). -/// On finish, records a FocusSession. +/// On finish, records a FocusSession AND marks the todo as completed. class TimerScreen extends StatefulWidget { final Todo todo; @@ -51,7 +51,7 @@ class _TimerScreenState extends State { _timer?.cancel(); _isRunning = false; _isFinished = true; - _recordSession(); + _recordSessionAndComplete(); } }); }); @@ -72,23 +72,38 @@ class _TimerScreenState extends State { _isRunning = false; _isFinished = true; }); - _recordSession(); + _recordSessionAndComplete(); } - void _recordSession() { + /// Record focus session AND mark the todo as completed. + void _recordSessionAndComplete() { final provider = context.read(); + + // Record focus session provider.recordFocusSession(FocusSession.create( todoId: widget.todo.id, sourceType: 'todo', sourceTitle: widget.todo.title, durationSeconds: _elapsedSeconds, )); - // Also update the todo's actual duration - final updated = widget.todo.copyWith( - actualDurationSeconds: - widget.todo.actualDurationSeconds + _elapsedSeconds, - ); - provider.updateTodo(updated); + + // Mark the todo as completed (this adds the strikethrough) + if (!widget.todo.isCompleted) { + final completed = widget.todo.copyWith( + isCompleted: true, + completedDate: DateTime.now().toIso8601String(), + actualDurationSeconds: + widget.todo.actualDurationSeconds + _elapsedSeconds, + ); + provider.updateTodo(completed); + } else { + // Already completed, just update duration + final updated = widget.todo.copyWith( + actualDurationSeconds: + widget.todo.actualDurationSeconds + _elapsedSeconds, + ); + provider.updateTodo(updated); + } } String _formatTime(int seconds) { @@ -225,6 +240,8 @@ class _TimerScreenState extends State { const SizedBox(height: 16), Text('专注 ${_formatTime(_elapsedSeconds)}', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text('待办已完成 ✓', style: theme.textTheme.bodyMedium?.copyWith(color: cs.primary)), const SizedBox(height: 24), FilledButton( onPressed: () => Navigator.pop(context), diff --git a/lib/screens/today_screen.dart b/lib/screens/today_screen.dart index 4489795..a5dc861 100644 --- a/lib/screens/today_screen.dart +++ b/lib/screens/today_screen.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; -import 'dart:io' as io; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -66,19 +64,18 @@ class _TodayScreenState extends State { UstcNews? news; try { + // fetchUstcNews now has a 3-tier fallback: + // 1. Supabase (cloud) → 2. Go backend → 3. Local Obsidian vault news = await ApiService.fetchUstcNews(); } catch (_) { news = null; } if (news == null) { + // Try latest available news (not necessarily today's) try { - // Try common Obsidian vault paths as fallback - final vaultPath = await _getVaultPath(); - if (vaultPath != null) { - news = await ApiService.fetchUstcNewsLocal(vaultPath); - } - } catch (e) { + news = await ApiService.fetchLatestUstcNews(); + } catch (_) { news = null; } } @@ -158,25 +155,6 @@ class _TodayScreenState extends State { ); } - Future _getVaultPath() async { - try { - // Check Obsidian config for vault path - final configFile = io.File( - '${io.Platform.environment['APPDATA']}/obsidian/obsidian.json'); - if (await configFile.exists()) { - final config = json.decode(await configFile.readAsString()); - final vaults = config['vaults'] as Map; - for (final v in vaults.values) { - final path = v['path'] as String?; - if (path != null && await io.Directory(path).exists()) { - return path; - } - } - } - } catch (_) {} - return null; - } - void _openNewsModal() { Navigator.of(context).push( MaterialPageRoute( diff --git a/lib/screens/todo_screen.dart b/lib/screens/todo_screen.dart index 691b6c2..2ca8476 100644 --- a/lib/screens/todo_screen.dart +++ b/lib/screens/todo_screen.dart @@ -19,28 +19,52 @@ class TodoScreen extends StatefulWidget { State createState() => _TodoScreenState(); } -class _TodoScreenState extends State { - // ---- Combined display list: TODOs first, then Habits ---- - List _combined(AppProvider p) => [...p.todos, ...p.habits]; +class _TodoScreenState extends State with SingleTickerProviderStateMixin { + // ---- Combined display list ---- + // Order: incomplete todos (by sortOrder) → completed todos (by completedDate desc) → habits + List _combined(AppProvider p) { + final incompleteTodos = p.todos.where((t) => !t.isCompleted).toList() + ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + final completedTodos = p.todos.where((t) => t.isCompleted).toList() + ..sort((a, b) { + final ad = a.completedDate ?? ''; + final bd = b.completedDate ?? ''; + return bd.compareTo(ad); // desc — most recently completed first + }); + return [...incompleteTodos, ...completedTodos, ...p.habits]; + } void _onReorder(int oldIndex, int newIndex) { final provider = context.read(); + final items = _combined(provider); final todoCount = provider.todos.length; final habitCount = provider.habits.length; - if (oldIndex < todoCount) { - // Dragged a TODO → reorder within todos (clamp into todo region) - var newTodoIndex = newIndex; - if (newTodoIndex < 0) newTodoIndex = 0; - if (newTodoIndex > todoCount) newTodoIndex = todoCount; - provider.reorderTodos(oldIndex, newTodoIndex); - } else { - // Dragged a Habit → reorder within habits - final oldHabitIndex = oldIndex - todoCount; - var newHabitIndex = newIndex - todoCount; + final oldItem = items[oldIndex]; + + // Determine the type of the dragged item + if (oldItem is Habit) { + // Habit drag — reorder within habits region + final oldHabitIndex = provider.habits.indexOf(oldItem); + final habitStart = todoCount; + var newHabitIndex = newIndex; + if (newIndex > habitStart) { + newHabitIndex -= habitStart; + } else if (newIndex < habitStart) { + newHabitIndex = 0; + } else { + newHabitIndex -= habitStart; + } if (newHabitIndex < 0) newHabitIndex = 0; if (newHabitIndex > habitCount) newHabitIndex = habitCount; provider.reorderHabits(oldHabitIndex, newHabitIndex); + } else if (oldItem is Todo) { + // Todo drag — reorder within todos region + final oldTodoIndex = provider.todos.indexOf(oldItem); + var newTodoIndex = newIndex; + if (newTodoIndex > todoCount) newTodoIndex = todoCount; + if (newTodoIndex < 0) newTodoIndex = 0; + provider.reorderTodos(oldTodoIndex, newTodoIndex); } } @@ -59,42 +83,46 @@ class _TodoScreenState extends State { } } - // ---- Add sheet: choose TODO or Habit ---- - void _showAddSheet() { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(top: 4, bottom: 8), - child: Text('新建', style: Theme.of(context).textTheme.titleMedium), - ), - ListTile( - leading: const Icon(Icons.check_circle_outline), - title: const Text('新建待办'), - subtitle: const Text('一次性任务 · 支持计时'), - onTap: () { - Navigator.pop(ctx); - _openTodoEdit(null); - }, - ), - ListTile( - leading: const Icon(Icons.repeat_rounded), - title: const Text('新建习惯'), - subtitle: const Text('每日打卡 · 计量目标'), - onTap: () { - Navigator.pop(ctx); - _openHabitEdit(null); - }, - ), - const SizedBox(height: 8), - ], - ), + // ---- Custom FAB overlay: two large buttons (TODO / Habit) ---- + void _showAddOverlay() { + final overlay = Overlay.of(context); + late OverlayEntry entry; + late AnimationController animController; + late Animation scaleAnim; + + animController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + scaleAnim = CurvedAnimation(parent: animController, curve: Curves.easeOutBack); + + entry = OverlayEntry( + builder: (ctx) => _AddOverlay( + scaleAnim: scaleAnim, + onDismiss: () { + animController.reverse().then((_) { + entry.remove(); + animController.dispose(); + }); + }, + onTodo: () { + animController.reverse().then((_) { + entry.remove(); + animController.dispose(); + }); + _openTodoEdit(null); + }, + onHabit: () { + animController.reverse().then((_) { + entry.remove(); + animController.dispose(); + }); + _openHabitEdit(null); + }, ), ); + overlay.insert(entry); + animController.forward(); } // ---- Long-press options: edit / delete ---- @@ -289,10 +317,124 @@ class _TodoScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: _showAddSheet, + onPressed: _showAddOverlay, tooltip: '添加', child: const Icon(Icons.add), ), ); } } + +// ---- Full-screen overlay with two large buttons ---- +class _AddOverlay extends StatelessWidget { + final Animation scaleAnim; + final VoidCallback onDismiss; + final VoidCallback onTodo; + final VoidCallback onHabit; + + const _AddOverlay({ + required this.scaleAnim, + required this.onDismiss, + required this.onTodo, + required this.onHabit, + }); + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + + return Material( + color: Colors.black54, + child: GestureDetector( + onTap: onDismiss, + behavior: HitTestBehavior.opaque, + child: Container( + width: screenSize.width, + height: screenSize.height, + color: Colors.transparent, + child: Center( + child: GestureDetector( + onTap: () {}, // swallow taps on the buttons row + child: ScaleTransition( + scale: scaleAnim, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Left button — TODO (25% width, orange) + _BigButton( + label: 'TODO', + color: const Color(0xFFE85D3C), + widthFraction: 0.25, + onTap: onTodo, + ), + const SizedBox(width: 16), + // Right button — Habit (75% width, blue) + _BigButton( + label: 'Habit', + color: const Color(0xFF4A90D9), + widthFraction: 0.75, + onTap: onHabit, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _BigButton extends StatelessWidget { + final String label; + final Color color; + final double widthFraction; + final VoidCallback onTap; + + const _BigButton({ + required this.label, + required this.color, + required this.widthFraction, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final screenW = MediaQuery.of(context).size.width; + final btnWidth = screenW * widthFraction; + // Keep ~120 tall, but if width fraction is small, use at least 120 + final btnHeight = 120.0; + + return GestureDetector( + onTap: onTap, + child: Container( + width: btnWidth, + height: btnHeight, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Center( + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w900, + letterSpacing: 1.5, + decoration: TextDecoration.none, + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index d4e6f64..74d4e7c 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,98 +1,65 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:http/http.dart' as http; -import '../models/models.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'database_service.dart'; +import 'supabase_config.dart'; -/// API service for backend sync (Go server) and USTC news fetching +/// API service for USTC news fetching and cloud data access. +/// Go backend has been removed — everything goes through Supabase. class ApiService { - static String? _baseUrl; - static bool _isConnected = false; - - static Future get baseUrl async { - if (_baseUrl != null) return _baseUrl!; - _baseUrl = await DatabaseService.getSetting('api_base_url') ?? - 'http://localhost:8765'; - return _baseUrl!; - } - - static Future setBaseUrl(String url) async { - _baseUrl = url; - await DatabaseService.setSetting('api_base_url', url); - } + // ============ USTC News ============ - static bool get isConnected => _isConnected; + /// Fetch USTC news with a 2-tier fallback chain: + /// 1. Supabase (cloud — works on all platforms without PC) + /// 2. Local Obsidian vault file (desktop only, fallback) + static Future fetchUstcNews({String? date}) async { + // Tier 1: Supabase cloud + final cloud = await _fetchUstcNewsFromSupabase(date); + if (cloud != null) return cloud; - /// Test connection to backend - static Future testConnection() async { - try { - final url = await baseUrl; - final res = await http.get(Uri.parse('$url/health')).timeout( - const Duration(seconds: 3), - ); - _isConnected = res.statusCode == 200; - return _isConnected; - } catch (_) { - _isConnected = false; - return false; - } + // Tier 2: Local vault file (desktop only) + return _fetchUstcNewsFromLocalVault(date); } - // ============ TODO Sync ============ - - static Future syncTodos(List todos) async { + /// Fetch from Supabase ustc_news table (anon RLS allows SELECT) + static Future _fetchUstcNewsFromSupabase(String? date) async { try { - final url = await baseUrl; - await http.post( - Uri.parse('$url/api/todos/sync'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'todos': todos.map((t) => t.toMap()).toList(), - }), - ).timeout(const Duration(seconds: 5)); - } catch (_) {} - } + if (!isSupabaseConfigured) return null; + final client = Supabase.instance.client; + final dateStr = date ?? DateTime.now().toIso8601String().substring(0, 10); - // ============ USTC News ============ + final data = await client + .from('ustc_news') + .select('date, title, content') + .eq('date', dateStr) + .maybeSingle() + .timeout(const Duration(seconds: 8)); - /// Fetch today's USTC news markdown from backend (which reads from Obsidian vault) - static Future fetchUstcNews({String? date}) async { - try { - final url = await baseUrl; - final endpoint = date != null - ? '$url/api/ustc-news?date=$date' - : '$url/api/ustc-news/today'; - final res = await http.get(Uri.parse(endpoint)).timeout( - const Duration(seconds: 5), - ); - if (res.statusCode == 200) { - final data = jsonDecode(res.body); + if (data != null) { return UstcNews( date: data['date'] as String, title: data['title'] as String, - markdown: data['markdown'] as String, + markdown: data['content'] as String, ); } } catch (_) {} return null; } - /// Fetch today's USTC news directly from the Obsidian vault file - /// (used as fallback when backend is not running) - static Future fetchUstcNewsLocal(String vaultPath) async { + /// Fetch directly from local Obsidian vault file (desktop fallback) + static Future _fetchUstcNewsFromLocalVault(String? date) async { try { - final now = DateTime.now(); - final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateStr = date ?? + DateTime.now().toIso8601String().substring(0, 10); + final vaultPath = await DatabaseService.getSetting('obsidian_vault_path') ?? + r'C:\Users\ASUS\Documents\Notes'; final file = File('$vaultPath/USTC 每日要闻/$dateStr.md'); if (await file.exists()) { final content = await file.readAsString(); - // Extract title from first H1 String? title; final titleMatch = RegExp(r'^#\s+(.+)$', multiLine: true).firstMatch(content); if (titleMatch != null) { title = titleMatch.group(1)!.trim(); } - // Strip frontmatter String markdown = content; if (markdown.startsWith('---')) { final end = markdown.indexOf('---', 3); @@ -110,9 +77,51 @@ class ApiService { return null; } - /// List available USTC news dates from the vault - static Future> listUstcNewsDates(String vaultPath) async { + /// Fetch latest available USTC news (most recent date in Supabase) + static Future fetchLatestUstcNews() async { + try { + if (isSupabaseConfigured) { + final client = Supabase.instance.client; + final data = await client + .from('ustc_news') + .select('date, title, content') + .order('date', ascending: false) + .limit(1) + .maybeSingle() + .timeout(const Duration(seconds: 8)); + if (data != null) { + return UstcNews( + date: data['date'] as String, + title: data['title'] as String, + markdown: data['content'] as String, + ); + } + } + } catch (_) {} + + // Fallback to local vault + return _fetchUstcNewsFromLocalVault(null); + } + + /// List available USTC news dates from Supabase or local vault + static Future> listUstcNewsDates() async { + try { + if (isSupabaseConfigured) { + final client = Supabase.instance.client; + final data = await client + .from('ustc_news') + .select('date') + .order('date', ascending: false) + .timeout(const Duration(seconds: 8)); + if (data.isNotEmpty) { + return data.map((row) => row['date'] as String).toList(); + } + } + } catch (_) {} + try { + final vaultPath = await DatabaseService.getSetting('obsidian_vault_path') ?? + r'C:\Users\ASUS\Documents\Notes'; final dir = Directory('$vaultPath/USTC 每日要闻'); if (!await dir.exists()) return []; final files = await dir.list().toList(); diff --git a/lib/services/app_locale.dart b/lib/services/app_locale.dart new file mode 100644 index 0000000..7798793 --- /dev/null +++ b/lib/services/app_locale.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +/// App locale provider — supports Chinese and English with hot-switch. +/// Also holds the theme mode for the MaterialApp. +/// Persisted to the database via AppProvider. +enum AppLocale { zh, en } + +class AppLocaleProvider extends ChangeNotifier { + AppLocale _locale = AppLocale.zh; + ThemeMode _themeMode = ThemeMode.system; + + AppLocale get locale => _locale; + ThemeMode get themeMode => _themeMode; + Locale get flutterLocale => _locale == AppLocale.zh + ? const Locale('zh') + : const Locale('en'); + + void setLocale(AppLocale locale) { + _locale = locale; + notifyListeners(); + } + + void setThemeMode(ThemeMode mode) { + _themeMode = mode; + notifyListeners(); + } + + void toggle() { + _locale = _locale == AppLocale.zh ? AppLocale.en : AppLocale.zh; + notifyListeners(); + } +} + +/// Centralized string translations. +class S { + final AppLocale locale; + S(this.locale); + + static S of(AppLocale locale) => S(locale); + + // ---- Nav ---- + String get todo => locale == AppLocale.zh ? '待办' : 'Todo'; + String get countdown => locale == AppLocale.zh ? '倒计时' : 'Countdown'; + String get today => locale == AppLocale.zh ? '今天' : 'Today'; + String get me => locale == AppLocale.zh ? '我的' : 'Me'; + + // ---- Todo ---- + String get addTodo => locale == AppLocale.zh ? '新建待办' : 'New Todo'; + String get addHabit => locale == AppLocale.zh ? '新建习惯' : 'New Habit'; + String get newItems => locale == AppLocale.zh ? '新建' : 'New'; + String get todoTitleHint => locale == AppLocale.zh ? '输入待办标题' : 'Enter todo title'; + String get timingMethod => locale == AppLocale.zh ? '计时方式' : 'Timing'; + String get forwardTimer => locale == AppLocale.zh ? '正向计时' : 'Count Up'; + String get backwardTimer => locale == AppLocale.zh ? '倒向计时' : 'Count Down'; + String get noTimer => locale == AppLocale.zh ? '不记时' : 'No Timer'; + String get duration => locale == AppLocale.zh ? '时长' : 'Duration'; + String get customMin => locale == AppLocale.zh ? '自定义' : 'Custom'; + String get customMinHint => locale == AppLocale.zh ? '自定义分钟数' : 'Custom minutes'; + String get keepTomorrow => locale == AppLocale.zh ? '明天继续' : 'Repeat Tomorrow'; + String get keepTomorrowDesc => locale == AppLocale.zh ? '完成后明天自动重建' : 'Auto-recreate tomorrow when done'; + String get completed => locale == AppLocale.zh ? '已完成' : 'Completed'; + String get habits => locale == AppLocale.zh ? '习惯' : 'Habits'; + String get noTodos => locale == AppLocale.zh ? '还没有待办事项' : 'No todos yet'; + String get noTodosHint => locale == AppLocale.zh ? '点击右下角 + 添加待办或习惯' : 'Tap + to add a todo or habit'; + String get oneTimeTask => locale == AppLocale.zh ? '一次性任务 · 支持计时' : 'One-time · With timer'; + String get dailyHabit => locale == AppLocale.zh ? '每日打卡 · 计量目标' : 'Daily · Track count'; + String get delete => locale == AppLocale.zh ? '删除' : 'Delete'; + String get edit => locale == AppLocale.zh ? '编辑' : 'Edit'; + String get confirmDelete => locale == AppLocale.zh ? '确定删除「\$0」吗?此操作不可撤销。' : 'Delete "\$0"? This cannot be undone.'; + String get cancel => locale == AppLocale.zh ? '取消' : 'Cancel'; + String get save => locale == AppLocale.zh ? '保存' : 'Save'; + String get enterTitle => locale == AppLocale.zh ? '请输入标题' : 'Please enter a title'; + + // ---- Habit ---- + String get habitName => locale == AppLocale.zh ? '习惯名称' : 'Habit name'; + String get targetCount => locale == AppLocale.zh ? '目标数量' : 'Target count'; + String get unit => locale == AppLocale.zh ? '单位' : 'Unit'; + String get daily => locale == AppLocale.zh ? '每日' : 'Daily'; + String get customUnit => locale == AppLocale.zh ? '自定义量词' : 'Custom unit'; + String get unitHint => locale == AppLocale.zh ? '输入自定义量词' : 'Enter custom unit'; + + // ---- Timer ---- + String get start => locale == AppLocale.zh ? '开始' : 'Start'; + String get pause => locale == AppLocale.zh ? '暂停' : 'Pause'; + String get stop => locale == AppLocale.zh ? '完成' : 'Done'; + String get record => locale == AppLocale.zh ? '记录' : 'Record'; + String get exitTimer => locale == AppLocale.zh ? '退出计时' : 'Exit Timer'; + String get recordPrompt => locale == AppLocale.zh ? '已计时 \$0,是否记录本次专注?' : 'Tracked \$0, record this session?'; + String get keepTiming => locale == AppLocale.zh ? '继续计时' : 'Keep Timing'; + String get recordAndExit => locale == AppLocale.zh ? '记录并退出' : 'Record & Exit'; + String get abandon => locale == AppLocale.zh ? '放弃' : 'Discard'; + String get back => locale == AppLocale.zh ? '返回' : 'Back'; + String get focusTime => locale == AppLocale.zh ? '专注 \$0' : 'Focused \$0'; + String get remaining => locale == AppLocale.zh ? '剩余时间' : 'Remaining'; + String get done => locale == AppLocale.zh ? '已完成' : 'Done'; + + // ---- Today ---- + String get todayFocus => locale == AppLocale.zh ? '今日专注' : "Today's Focus"; + String get pomodoroCount => locale == AppLocale.zh ? '今日番茄数' : 'Pomodoros'; + String get timeAllocation => locale == AppLocale.zh ? '时间分配' : 'Time Allocation'; + String get last7days => locale == AppLocale.zh ? '最近 7 天' : 'Last 7 Days'; + String get noFocusData => locale == AppLocale.zh ? '今天还没有专注数据' : 'No focus data today'; + String get ustcNews => locale == AppLocale.zh ? 'USTC 要闻' : 'USTC News'; + String get noNews => locale == AppLocale.zh ? '暂无 USTC 要闻' : 'No USTC news available'; + String get retry => locale == AppLocale.zh ? '重试' : 'Retry'; + String get viewNews => locale == AppLocale.zh ? '查看今日 USTC 要闻' : 'View Today\'s USTC News'; + String get loadFailed => locale == AppLocale.zh ? '加载失败' : 'Failed to load'; + + // ---- Me ---- + String get checkIn => locale == AppLocale.zh ? '打卡' : 'Check-in'; + String get stats => locale == AppLocale.zh ? '统计' : 'Stats'; + String get settings => locale == AppLocale.zh ? '设置' : 'Settings'; + String get todayCheckIn => locale == AppLocale.zh ? '今日打卡' : "Today's Check-in"; + String get wakeUp => locale == AppLocale.zh ? '起床' : 'Wake Up'; + String get sleep => locale == AppLocale.zh ? '睡觉' : 'Sleep'; + String get workout => locale == AppLocale.zh ? '健身' : 'Workout'; + String get notCheckedIn => locale == AppLocale.zh ? '未打卡' : 'Not checked in'; + String get checkInHistory => locale == AppLocale.zh ? '打卡记录' : 'History'; + String get noCheckInRecords => locale == AppLocale.zh ? '暂无打卡记录' : 'No records yet'; + String get todoCompleted => locale == AppLocale.zh ? '待办完成' : 'Todos Done'; + String get habitCompleted => locale == AppLocale.zh ? '习惯完成' : 'Habits Done'; + String get activeCountdowns => locale == AppLocale.zh ? '活跃倒计时' : 'Active Countdowns'; + + // ---- Settings ---- + String get cloudSync => locale == AppLocale.zh ? '云端同步' : 'Cloud Sync'; + String get connected => locale == AppLocale.zh ? '已连接' : 'Connected'; + String get notConnected => locale == AppLocale.zh ? '未连接' : 'Not Connected'; + String get connectedToSupabase => locale == AppLocale.zh ? '已连接 Supabase' : 'Connected to Supabase'; + String get user => locale == AppLocale.zh ? '用户' : 'User'; + String get unknown => locale == AppLocale.zh ? '未知' : 'Unknown'; + String get logout => locale == AppLocale.zh ? '退出登录' : 'Sign Out'; + String get logoutConfirm => locale == AppLocale.zh ? '确定退出登录?本地数据会保留,重新登录后将从云端同步。' : 'Sign out? Local data is kept; re-login will sync from cloud.'; + String get signOut => locale == AppLocale.zh ? '退出' : 'Sign Out'; + String get checkUpdate => locale == AppLocale.zh ? '检查更新' : 'Check for Updates'; + String get checkingUpdate => locale == AppLocale.zh ? '正在检查更新...' : 'Checking for updates...'; + String get latestVersion => locale == AppLocale.zh ? '当前已是最新版本' : 'Already up to date'; + String get aboutGoWorkBro => locale == AppLocale.zh ? '关于 GoWorkBro' : 'About GoWorkBro'; + String get version => locale == AppLocale.zh ? '版本' : 'Version'; + String get openSourceLicenses => locale == AppLocale.zh ? '开源许可' : 'Open Source Licenses'; + String get language => locale == AppLocale.zh ? '语言' : 'Language'; + String get chinese => locale == AppLocale.zh ? '中文' : 'Chinese'; + String get english => locale == AppLocale.zh ? '英文' : 'English'; + String get theme => locale == AppLocale.zh ? '主题' : 'Theme'; + String get lightMode => locale == AppLocale.zh ? '浅色' : 'Light'; + String get darkMode => locale == AppLocale.zh ? '深色' : 'Dark'; + String get systemMode => locale == AppLocale.zh ? '跟随系统' : 'System'; + String get deleteData => locale == AppLocale.zh ? '删除所有数据' : 'Delete All Data'; + String get deleteDataConfirm => locale == AppLocale.zh ? '确定删除所有本地数据?此操作不可撤销,包括待办、习惯、打卡记录等。' : 'Delete all local data? This is irreversible and includes todos, habits, check-in records, etc.'; + String get deleteDataSuccess => locale == AppLocale.zh ? '所有数据已删除' : 'All data deleted'; + String get avatarUpload => locale == AppLocale.zh ? '更换头像' : 'Change Avatar'; + String get avatarHint => locale == AppLocale.zh ? '头像功能开发中,敬请期待 🚀' : 'Avatar upload coming soon 🚀'; + String get editName => locale == AppLocale.zh ? '编辑昵称' : 'Edit Name'; + String get nameHint => locale == AppLocale.zh ? '输入昵称' : 'Enter name'; + + // ---- Countdown ---- + String get noCountdowns => locale == AppLocale.zh ? '还没有倒计时' : 'No countdowns'; + String get addCountdownHint => locale == AppLocale.zh ? '点击右下角 + 添加' : 'Tap + to add'; + String get countdownTitle => locale == AppLocale.zh ? '倒计时标题' : 'Countdown title'; + String get targetDate => locale == AppLocale.zh ? '目标日期' : 'Target date'; + String get targetTime => locale == AppLocale.zh ? '目标时间' : 'Target time'; + String get days => locale == AppLocale.zh ? '天' : 'd'; + String get hours => locale == AppLocale.zh ? '时' : 'h'; + String get mins => locale == AppLocale.zh ? '分' : 'm'; + String get secs => locale == AppLocale.zh ? '秒' : 's'; + String get expired => locale == AppLocale.zh ? '已过期' : 'Expired'; +} diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index bcf2281..0d380cd 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -18,8 +18,9 @@ class DatabaseService { _db = await dbFactory.openDatabase( dbPath, options: OpenDatabaseOptions( - version: 1, + version: 2, onCreate: _onCreate, + onUpgrade: _onUpgrade, ), ); return _db!; @@ -93,6 +94,7 @@ class DatabaseService { record_date TEXT NOT NULL, wake_time TEXT, sleep_time TEXT, + workout_time TEXT, note TEXT ) '''); @@ -110,6 +112,31 @@ class DatabaseService { await db.insert('user_settings', {'key': 'api_base_url', 'value': 'http://localhost:8765'}); } + /// Handle database schema migrations between versions. + static Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + // v1 -> v2: add workout_time column to sleep_records + if (oldVersion < 2) { + await db.execute('ALTER TABLE sleep_records ADD COLUMN workout_time TEXT;'); + } + } + + /// Drop all tables and recreate the database from scratch. + /// Used by "delete all data" — wipes todos, habits, focus sessions, + /// countdowns, sleep records, and user settings. + static Future deleteAllData() async { + final db = await database; + await db.transaction((txn) async { + await txn.execute('DROP TABLE IF EXISTS todos'); + await txn.execute('DROP TABLE IF EXISTS habits'); + await txn.execute('DROP TABLE IF EXISTS habit_logs'); + await txn.execute('DROP TABLE IF EXISTS focus_sessions'); + await txn.execute('DROP TABLE IF EXISTS countdowns'); + await txn.execute('DROP TABLE IF EXISTS sleep_records'); + await txn.execute('DROP TABLE IF EXISTS user_settings'); + }); + await _onCreate(db, 2); + } + // ============ TODO CRUD ============ static Future> getTodos() async { @@ -344,6 +371,7 @@ class DatabaseService { 'record_date': row['record_date'], 'wake_time': row['wake_time'], 'sleep_time': row['sleep_time'], + 'workout_time': row['workout_time'], 'note': row['note'], }, conflictAlgorithm: ConflictAlgorithm.replace); } diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 51920a1..c223052 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -31,7 +31,7 @@ class SyncService { await Supabase.initialize( url: supabaseUrl, - anonKey: supabaseAnonKey, + publishableKey: supabaseAnonKey, debug: kDebugMode, ); _client = Supabase.instance.client; @@ -262,9 +262,7 @@ class SyncService { table: 'todos', callback: (payload) { debugPrint('Realtime todo change: ${payload.eventType}'); - if (payload.newRecord != null) { - DatabaseService.upsertTodoFromRemote(payload.newRecord!); - } + DatabaseService.upsertTodoFromRemote(payload.newRecord); }, ) .subscribe(); @@ -276,9 +274,7 @@ class SyncService { schema: 'public', table: 'habits', callback: (payload) { - if (payload.newRecord != null) { - DatabaseService.upsertHabitFromRemote(payload.newRecord!); - } + DatabaseService.upsertHabitFromRemote(payload.newRecord); }, ) .subscribe(); @@ -290,9 +286,7 @@ class SyncService { schema: 'public', table: 'countdowns', callback: (payload) { - if (payload.newRecord != null) { - DatabaseService.upsertCountdownFromRemote(payload.newRecord!); - } + DatabaseService.upsertCountdownFromRemote(payload.newRecord); }, ) .subscribe(); @@ -304,9 +298,7 @@ class SyncService { schema: 'public', table: 'sleep_records', callback: (payload) { - if (payload.newRecord != null) { - DatabaseService.upsertSleepFromRemote(payload.newRecord!); - } + DatabaseService.upsertSleepFromRemote(payload.newRecord); }, ) .subscribe(); @@ -318,9 +310,7 @@ class SyncService { schema: 'public', table: 'focus_sessions', callback: (payload) { - if (payload.newRecord != null) { - DatabaseService.insertFocusSessionIfNotExists(payload.newRecord!); - } + DatabaseService.insertFocusSessionIfNotExists(payload.newRecord); }, ) .subscribe(); diff --git a/lib/services/tray_service.dart b/lib/services/tray_service.dart new file mode 100644 index 0000000..10bb051 --- /dev/null +++ b/lib/services/tray_service.dart @@ -0,0 +1,85 @@ +import 'dart:io'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +/// Manages the system tray icon and window visibility for background mode. +/// +/// On Windows desktop, closing the window hides it to the tray instead of +/// quitting. The user can show the window again from the tray menu or +/// truly quit via "退出". +class TrayService with TrayListener { + static final TrayService _instance = TrayService._(); + factory TrayService() => _instance; + TrayService._(); + + bool _initialized = false; + + Future init() async { + if (_initialized) return; + if (!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) return; + + await trayManager.setIcon( + Platform.isWindows + ? 'assets/icons/app_icon.ico' + : 'assets/icons/app_icon.png', + ); + await trayManager.setToolTip('GoWorkBro — 正在后台运行'); + await trayManager.setContextMenu(Menu(items: [ + MenuItem( + key: 'show', + label: '显示主窗口', + ), + MenuItem.separator(), + MenuItem( + key: 'quit', + label: '退出 GoWorkBro', + ), + ])); + + trayManager.addListener(this); + _initialized = true; + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + switch (menuItem.key) { + case 'show': + _showWindow(); + break; + case 'quit': + _quitApp(); + break; + } + } + + @override + void onTrayIconMouseDown() { + _showWindow(); + } + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + void _showWindow() async { + await windowManager.show(); + await windowManager.focus(); + if (await windowManager.isMinimized()) { + await windowManager.restore(); + } + } + + void _quitApp() async { + await trayManager.destroy(); + await windowManager.setPreventClose(false); + await windowManager.destroy(); + } + + Future dispose() async { + if (!_initialized) return; + trayManager.removeListener(this); + await trayManager.destroy(); + _initialized = false; + } +} diff --git a/lib/widgets/today/ustc_news_section.dart b/lib/widgets/today/ustc_news_section.dart index efa54c2..807becb 100644 --- a/lib/widgets/today/ustc_news_section.dart +++ b/lib/widgets/today/ustc_news_section.dart @@ -113,7 +113,14 @@ class NewsView extends StatelessWidget { selectable: true, padding: EdgeInsets.zero, styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( - p: theme.textTheme.bodyMedium, + p: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'LXGWWenKai', + height: 1.8, + ), + h1: theme.textTheme.headlineMedium?.copyWith( + fontFamily: 'LXGWWenKai', + fontWeight: FontWeight.w700, + ), h2: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/widgets/todo/habit_card.dart b/lib/widgets/todo/habit_card.dart index 447a80a..06959c3 100644 --- a/lib/widgets/todo/habit_card.dart +++ b/lib/widgets/todo/habit_card.dart @@ -69,7 +69,7 @@ class HabitCard extends StatelessWidget { ), const SizedBox(width: 8), Text( - '${habit.currentCount}/${habit.targetCount} ${habit.unit}', + '每日 ${habit.currentCount}/${habit.targetCount} ${habit.unit}', style: theme.textTheme.bodySmall?.copyWith( color: done ? cs.primary : theme.hintColor, fontWeight: FontWeight.w600, diff --git a/lib/widgets/todo/habit_edit_dialog.dart b/lib/widgets/todo/habit_edit_dialog.dart index 8ebe825..96a4d0c 100644 --- a/lib/widgets/todo/habit_edit_dialog.dart +++ b/lib/widgets/todo/habit_edit_dialog.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../models/models.dart'; -/// Dialog for creating/editing habits — extracted from todo_screen.dart +/// Dialog for creating/editing habits — with custom unit support class HabitEditDialog extends StatefulWidget { final Habit? initial; const HabitEditDialog({super.key, this.initial}); @@ -12,11 +13,14 @@ class HabitEditDialog extends StatefulWidget { } class _HabitEditDialogState extends State { - static const _units = ['次', '分钟', '小时', '个', '页', '道']; + static const _presetUnits = ['次', '分钟', '小时', '个', '页', '道']; late final TextEditingController _titleCtrl; late final TextEditingController _targetCtrl; + late final TextEditingController _customUnitCtrl; late String _unit; + bool _isCustomUnit = false; + List _savedUnits = []; @override void initState() { @@ -25,13 +29,39 @@ class _HabitEditDialogState extends State { _targetCtrl = TextEditingController(text: (widget.initial?.targetCount ?? 1).toString()); final u = widget.initial?.unit ?? '次'; - _unit = _units.contains(u) ? u : '次'; + _customUnitCtrl = TextEditingController(); + _loadSavedUnits(); + if (_presetUnits.contains(u)) { + _unit = u; + _isCustomUnit = false; + } else { + _unit = u; + _isCustomUnit = true; + _customUnitCtrl.text = u; + } + } + + Future _loadSavedUnits() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _savedUnits = prefs.getStringList('custom_habit_units') ?? []; + }); + } + + Future _saveCustomUnit(String unit) async { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList('custom_habit_units') ?? []; + if (!list.contains(unit)) { + list.add(unit); + await prefs.setStringList('custom_habit_units', list); + } } @override void dispose() { _titleCtrl.dispose(); _targetCtrl.dispose(); + _customUnitCtrl.dispose(); super.dispose(); } @@ -48,18 +78,27 @@ class _HabitEditDialogState extends State { } var target = int.tryParse(_targetCtrl.text.trim()) ?? 1; if (target < 1) target = 1; + + String finalUnit = _unit; + if (_isCustomUnit) { + finalUnit = _customUnitCtrl.text.trim(); + if (finalUnit.isEmpty) finalUnit = '次'; + _saveCustomUnit(finalUnit); + } + final Habit result; if (widget.initial == null) { - result = Habit.create(title: title, targetCount: target, unit: _unit); + result = Habit.create(title: title, targetCount: target, unit: finalUnit); } else { result = widget.initial!.copyWith( - title: title, targetCount: target, unit: _unit); + title: title, targetCount: target, unit: finalUnit); } Navigator.of(context).pop(result); } @override Widget build(BuildContext context) { + final theme = Theme.of(context); return AlertDialog( title: Text(widget.initial == null ? '新建习惯' : '编辑习惯'), content: SingleChildScrollView( @@ -77,22 +116,58 @@ class _HabitEditDialogState extends State { controller: _targetCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( - hintText: '目标数量', - suffixText: '次', + hintText: '每日目标数量', + prefixText: '每日 ', ), ), const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _unit, - decoration: const InputDecoration(labelText: '单位'), - items: _units - .map((u) => DropdownMenuItem( - value: u, - child: Text(u), - )) - .toList(), - onChanged: (v) => setState(() => _unit = v ?? '次'), + Text('量词', style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + ..._presetUnits.map((u) => ChoiceChip( + label: Text(u), + selected: !_isCustomUnit && _unit == u, + onSelected: (_) { + setState(() { + _unit = u; + _isCustomUnit = false; + }); + }, + )), + ..._savedUnits + .where((u) => !_presetUnits.contains(u)) + .map((u) => ChoiceChip( + label: Text(u), + selected: !_isCustomUnit && _unit == u, + onSelected: (_) { + setState(() { + _unit = u; + _isCustomUnit = false; + }); + }, + )), + ChoiceChip( + label: const Text('自定义'), + selected: _isCustomUnit, + onSelected: (_) { + setState(() => _isCustomUnit = true); + }, + ), + ], ), + if (_isCustomUnit) ...[ + const SizedBox(height: 8), + TextField( + controller: _customUnitCtrl, + decoration: const InputDecoration( + hintText: '输入自定义量词', + isDense: true, + ), + ), + ], ], ), ), diff --git a/lib/widgets/todo/todo_card.dart b/lib/widgets/todo/todo_card.dart index 7edc385..e8def50 100644 --- a/lib/widgets/todo/todo_card.dart +++ b/lib/widgets/todo/todo_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../models/models.dart'; -/// TODO item card widget — extracted from todo_screen.dart +/// TODO item card widget — no circle icon (tap to toggle complete) class TodoCard extends StatelessWidget { final Todo todo; final int index; @@ -52,26 +52,7 @@ class TodoCard extends StatelessWidget { ), ), const SizedBox(width: 6), - GestureDetector( - onTap: onToggle, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isDone ? cs.primary : Colors.transparent, - border: Border.all( - color: isDone ? cs.primary : theme.dividerColor, - width: 2, - ), - ), - child: isDone - ? const Icon(Icons.check, size: 16, color: Colors.white) - : null, - ), - ), - const SizedBox(width: 12), + // No circle icon — just tap the card to toggle Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/todo/todo_edit_dialog.dart b/lib/widgets/todo/todo_edit_dialog.dart index 04be10f..55e9bbe 100644 --- a/lib/widgets/todo/todo_edit_dialog.dart +++ b/lib/widgets/todo/todo_edit_dialog.dart @@ -15,7 +15,7 @@ class _TodoEditDialogState extends State { late final TextEditingController _titleCtrl; late final TextEditingController _customCtrl; TimingType _timingType = TimingType.forward; - String _durationChoice = '25'; // '25' | '40' | 'custom' + String _durationChoice = '25'; // '15' | '25' | '40' | 'custom' bool _keepTomorrow = true; @override @@ -26,7 +26,9 @@ class _TodoEditDialogState extends State { _timingType = t?.timingType ?? TimingType.forward; _keepTomorrow = t?.keepTomorrow ?? true; if (t != null && t.timingType == TimingType.backward) { - if (t.durationMinutes == 25) { + if (t.durationMinutes == 15) { + _durationChoice = '15'; + } else if (t.durationMinutes == 25) { _durationChoice = '25'; } else if (t.durationMinutes == 40) { _durationChoice = '40'; @@ -51,6 +53,8 @@ class _TodoEditDialogState extends State { return widget.initial?.durationMinutes ?? 25; } switch (_durationChoice) { + case '15': + return 15; case '25': return 25; case '40': @@ -143,8 +147,14 @@ class _TodoEditDialogState extends State { const SizedBox(height: 8), Text('时长', style: theme.textTheme.labelLarge), Wrap( - spacing: 8, + spacing: 10, + runSpacing: 8, children: [ + ChoiceChip( + label: const Text('15min'), + selected: _durationChoice == '15', + onSelected: (_) => setState(() => _durationChoice = '15'), + ), ChoiceChip( label: const Text('25min'), selected: _durationChoice == '25', @@ -179,7 +189,7 @@ class _TodoEditDialogState extends State { value: _keepTomorrow, onChanged: (v) => setState(() => _keepTomorrow = v ?? false), title: const Text('明天继续'), - subtitle: const Text('未完成时自动延续到次日'), + subtitle: const Text('完成后明天自动重建'), dense: true, contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, diff --git a/pubspec.lock b/pubspec.lock index c4e7264..292ef07 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -375,6 +375,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -671,6 +679,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sky_engine: dependency: transitive description: flutter @@ -796,6 +812,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: bdc3ac6c36f3d12d871459e4a9822705ce5a1165a17fa837103bc842719bf3f7 + url: "https://pub.dev" + source: hosted + version: "0.2.4" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 78ed5fe..a4f840f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,9 @@ dependencies: # Desktop window management window_manager: ^0.4.2 + # System tray icon for Windows background mode + tray_manager: ^0.2.3 + dev_dependencies: flutter_test: sdk: flutter @@ -56,3 +59,8 @@ flutter: assets: - assets/icons/ + + fonts: + - family: LXGWWenKai + fonts: + - asset: assets/fonts/LXGWWenKai-Regular.ttf \ No newline at end of file diff --git a/scripts/upload_ustc_news.py b/scripts/upload_ustc_news.py new file mode 100644 index 0000000..a0a7991 --- /dev/null +++ b/scripts/upload_ustc_news.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Upload USTC daily news to Supabase. + +Usage: + python upload_ustc_news.py + +Reads the markdown file, strips frontmatter, extracts H1 title, +and upserts into the Supabase ustc_news table. + +Environment variables (optional, override defaults): + SUPABASE_URL — Supabase project URL + SUPABASE_ANON_KEY — Supabase anon/publishable key +""" + +import sys +import re +import os +import json +import urllib.request + +# ============ Config ============ + +SUPABASE_URL = os.environ.get( + "SUPABASE_URL", + "https://icsulquyravumynznisa.supabase.co", +) +SUPABASE_ANON_KEY = os.environ.get( + "SUPABASE_ANON_KEY", + "sb_publishable_HRd65D5M-BIcBakD3XQBSQ_iG9lC6fK", +) + +VAULT_PATH = os.environ.get( + "OBSIDIAN_VAULT", + r"C:\Users\ASUS\Documents\Notes", +) + +USTC_NEWS_FOLDER = "USTC 每日要闻" + +# ============ Helpers ============ + + +def strip_frontmatter(md: str) -> str: + """Remove YAML frontmatter (--- ... ---) from markdown.""" + if md.startswith("---"): + end = md.find("---", 3) + if end > 0: + return md[end + 3 :].strip() + return md.strip() + + +def extract_title(md: str) -> str: + """Extract the first H1 heading as the title.""" + match = re.search(r"^#\s+(.+)$", md, re.MULTILINE) + if match: + return match.group(1).strip() + return "" + + +def upload(date_str: str, title: str, content: str) -> bool: + """Upsert news into Supabase ustc_news table.""" + url = f"{SUPABASE_URL}/rest/v1/ustc_news" + headers = { + "apikey": SUPABASE_ANON_KEY, + "Authorization": f"Bearer {SUPABASE_ANON_KEY}", + "Content-Type": "application/json", + "Prefer": "resolution=merge-duplicates", # upsert + } + payload = json.dumps( + { + "date": date_str, + "title": title, + "content": content, + } + ).encode("utf-8") + + req = urllib.request.Request(url, data=payload, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status in (200, 201): + print(f"✅ Uploaded USTC news for {date_str}: {title}") + return True + else: + print(f"❌ Upload failed: HTTP {resp.status}") + return False + except Exception as e: + print(f"❌ Upload error: {e}") + return False + + +def main(): + if len(sys.argv) >= 3: + date_str = sys.argv[1] + file_path = sys.argv[2] + elif len(sys.argv) == 1: + # Auto-detect today's file + from datetime import date + + date_str = date.today().isoformat() + file_path = os.path.join(VAULT_PATH, USTC_NEWS_FOLDER, f"{date_str}.md") + else: + print("Usage: python upload_ustc_news.py ") + sys.exit(1) + + if not os.path.exists(file_path): + print(f"❌ File not found: {file_path}") + sys.exit(1) + + with open(file_path, "r", encoding="utf-8") as f: + raw = f.read() + + content = strip_frontmatter(raw) + title = extract_title(content) or f"USTC 每日要闻 — {date_str}" + + success = upload(date_str, title, content) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 71a9d0e..0099896 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AppLinksPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index eda2b62..3490166 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links screen_retriever_windows + tray_manager url_launcher_windows window_manager )