diff --git a/lib/core/location_share.dart b/lib/core/location_share.dart index 69519ba..55c3324 100644 --- a/lib/core/location_share.dart +++ b/lib/core/location_share.dart @@ -1,3 +1,4 @@ +import 'package:characters/characters.dart'; import 'package:geolocator/geolocator.dart'; /// Controls how much coordinate detail is included in a shared map link. @@ -16,6 +17,36 @@ enum LocationSharePrecision { }; } +/// A short, transit-oriented status included in a social update. +enum SocialShareActivity { + waiting, + riding, + arriving, + meetUp; + + String get label => switch (this) { + waiting => '正在等車', + riding => '正在搭車', + arriving => '即將抵達', + meetUp => '想約人同行', + }; +} + +/// How long the sender considers an update relevant. +/// +/// This is deliberately described as a viewing deadline, rather than a hard +/// expiry: content copied to another app cannot be remotely revoked. +enum SocialShareDuration { + fifteenMinutes(Duration(minutes: 15), '15 分鐘'), + oneHour(Duration(hours: 1), '1 小時'), + oneDay(Duration(days: 1), '24 小時'); + + const SocialShareDuration(this.duration, this.label); + + final Duration duration; + final String label; +} + /// Creates a one-time map link for the system share sheet. /// /// The app deliberately does not persist or upload the coordinates. The @@ -54,3 +85,60 @@ class LocationShareMessage { '這是一次性分享,不會持續追蹤。'; } } + +/// Builds a privacy-conscious social update for the system share sheet. +class SocialShareMessage { + const SocialShareMessage._(); + + static const int noteMaxLength = 120; + + static String normalizeNote(String note) { + final normalized = note.trim().replaceAll(RegExp(r'\s+'), ' '); + if (normalized.characters.length <= noteMaxLength) { + return normalized; + } + return normalized.characters.take(noteMaxLength).toString().trimRight(); + } + + static String compose({ + required String displayName, + required SocialShareActivity activity, + required SocialShareDuration duration, + Position? position, + LocationSharePrecision precision = LocationSharePrecision.approximate, + String note = '', + DateTime? createdAt, + }) { + final sender = displayName.trim(); + final created = (createdAt ?? DateTime.now()).toLocal(); + final viewUntil = created.add(duration.duration); + final normalizedNote = normalizeNote(note); + final lines = [ + '${sender.isEmpty ? '我的' : '$sender 的'}近況|${activity.label}', + if (normalizedNote.isNotEmpty) normalizedNote, + if (position != null) ...[ + '', + '位置(${precision.label})', + LocationShareMessage.mapUrl( + latitude: position.latitude, + longitude: position.longitude, + precision: precision, + ), + precision == LocationSharePrecision.exact + ? '含有精確座標,請只分享給信任的人。' + : '位置已簡化為約 100 公尺範圍。', + ], + '', + '分享時間:${_formatDateTime(created)}', + '建議查看至:${_formatDateTime(viewUntil)}(${duration.label})', + '這是一次性分享;複製或轉傳到其他 App 後無法遠端撤回。', + ]; + return lines.join('\n'); + } + + static String _formatDateTime(DateTime value) { + String twoDigits(int number) => number.toString().padLeft(2, '0'); + return '${value.year}/${twoDigits(value.month)}/${twoDigits(value.day)} ' + '${twoDigits(value.hour)}:${twoDigits(value.minute)}'; + } +} diff --git a/lib/screens/social_screen.dart b/lib/screens/social_screen.dart index 9795226..b0bc6f7 100644 --- a/lib/screens/social_screen.dart +++ b/lib/screens/social_screen.dart @@ -19,28 +19,46 @@ class SocialScreen extends StatefulWidget { } class _SocialScreenState extends State { + final TextEditingController _noteController = TextEditingController(); + SocialShareActivity _activity = SocialShareActivity.waiting; + SocialShareDuration _duration = SocialShareDuration.oneHour; LocationSharePrecision _precision = LocationSharePrecision.approximate; + bool _includeLocation = false; bool _sharing = false; - Future _shareLocation() async { + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + Future _shareUpdate() async { if (_sharing) { return; } + final displayName = + AppControllerScope.of(context).authSession?.displayName ?? ''; setState(() => _sharing = true); try { - final position = await resolveUserPosition( - timeLimit: const Duration(seconds: 8), - accuracy: _precision == LocationSharePrecision.exact - ? LocationAccuracy.high - : LocationAccuracy.medium, - ); - final shareText = LocationShareMessage.text( + final position = _includeLocation + ? await resolveUserPosition( + timeLimit: const Duration(seconds: 8), + accuracy: _precision == LocationSharePrecision.exact + ? LocationAccuracy.high + : LocationAccuracy.medium, + ) + : null; + final shareText = SocialShareMessage.compose( + displayName: displayName, + activity: _activity, + duration: _duration, + note: _noteController.text, position: position, precision: _precision, ); final result = await SharePlus.instance.share( - ShareParams(text: shareText, subject: 'YetAnotherBusApp 位置分享'), + ShareParams(text: shareText, subject: 'YetAnotherBusApp 動態分享'), ); if (!mounted) { @@ -48,7 +66,7 @@ class _SocialScreenState extends State { } if (result.status == ShareResultStatus.unavailable) { await Clipboard.setData(ClipboardData(text: shareText)); - _showMessage('無法開啟系統分享面板,已將位置連結複製到剪貼簿。'); + _showMessage('無法開啟系統分享面板,已將動態複製到剪貼簿。'); } else { _showMessage('已開啟分享面板。'); } @@ -58,7 +76,7 @@ class _SocialScreenState extends State { } final failure = error is LocationFailure ? error : null; _showMessage( - failure?.message ?? '分享位置失敗:${friendlyErrorMessage(error)}', + failure?.message ?? '分享動態失敗:${friendlyErrorMessage(error)}', action: failure?.serviceDisabled == true ? SnackBarAction( label: '定位設定', @@ -88,6 +106,10 @@ class _SocialScreenState extends State { Widget build(BuildContext context) { final controller = AppControllerScope.of(context); final theme = Theme.of(context); + final displayName = controller.authSession?.displayName.trim(); + final senderName = displayName == null || displayName.isEmpty + ? '你' + : displayName; return Scaffold( appBar: AppBar(title: const Text('社交與位置分享')), @@ -106,45 +128,143 @@ class _SocialScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('分享你現在在哪裡', style: theme.textTheme.titleLarge), + Row( + children: [ + CircleAvatar( + child: Text( + senderName.characters.first.toUpperCase(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '分享新動態', + style: theme.textTheme.titleLarge, + ), + Text( + '以 $senderName 的身分分享', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ], + ), const SizedBox(height: 8), Text( - '只有在你按下按鈕後才會取得位置。位置不會寫入帳號同步,也不會持續追蹤。', + '告訴朋友你的交通狀態,也可選擇性附上目前位置。', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 20), - Text('分享精度', style: theme.textTheme.titleSmall), + Text('你正在做什麼?', style: theme.textTheme.titleSmall), const SizedBox(height: 8), - SegmentedButton( - expandedInsets: EdgeInsets.zero, - segments: [ - for (final option in LocationSharePrecision.values) - ButtonSegment( - value: option, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final option in SocialShareActivity.values) + ChoiceChip( label: Text(option.label), - icon: Icon( - option == LocationSharePrecision.exact - ? Icons.gps_fixed_rounded - : Icons.location_searching_rounded, + selected: _activity == option, + onSelected: _sharing + ? null + : (_) => setState(() => _activity = option), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _noteController, + enabled: !_sharing, + maxLength: SocialShareMessage.noteMaxLength, + maxLines: 3, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: '想說什麼?(選填)', + hintText: '例如:我在 2 號出口等你', + border: OutlineInputBorder(), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _includeLocation, + onChanged: _sharing + ? null + : (value) => + setState(() => _includeLocation = value), + title: const Text('附上目前位置'), + subtitle: const Text('關閉時不會請求定位權限'), + secondary: const Icon(Icons.location_on_outlined), + ), + if (_includeLocation) ...[ + const SizedBox(height: 8), + SegmentedButton( + expandedInsets: EdgeInsets.zero, + segments: [ + for (final option + in LocationSharePrecision.values) + ButtonSegment( + value: option, + label: Text(option.label), + icon: Icon( + option == LocationSharePrecision.exact + ? Icons.gps_fixed_rounded + : Icons.location_searching_rounded, + ), ), + ], + selected: {_precision}, + onSelectionChanged: _sharing + ? null + : (selected) => setState( + () => _precision = selected.first, + ), + ), + const SizedBox(height: 8), + Text( + _precision.description, + style: theme.textTheme.bodySmall, + ), + ], + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _duration, + decoration: const InputDecoration( + labelText: '建議查看期限', + prefixIcon: Icon(Icons.timer_outlined), + border: OutlineInputBorder(), + ), + items: [ + for (final option in SocialShareDuration.values) + DropdownMenuItem( + value: option, + child: Text(option.label), ), ], - selected: {_precision}, - onSelectionChanged: _sharing + onChanged: _sharing ? null - : (selected) => - setState(() => _precision = selected.first), + : (value) { + if (value != null) { + setState(() => _duration = value); + } + }, ), - const SizedBox(height: 8), - Text( - _precision.description, - style: theme.textTheme.bodySmall, + const SizedBox(height: 16), + _UpdatePreview( + activity: _activity, + note: _noteController.text, + includeLocation: _includeLocation, + precision: _precision, + duration: _duration, ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: _sharing ? null : _shareLocation, + onPressed: _sharing ? null : _shareUpdate, icon: _sharing ? const SizedBox.square( dimension: 18, @@ -152,8 +272,12 @@ class _SocialScreenState extends State { strokeWidth: 2, ), ) - : const Icon(Icons.share_location_rounded), - label: Text(_sharing ? '正在取得位置…' : '分享目前位置'), + : const Icon(Icons.ios_share_rounded), + label: Text( + _sharing + ? (_includeLocation ? '正在取得位置…' : '正在開啟分享…') + : '分享動態', + ), ), ), ], @@ -171,6 +295,89 @@ class _SocialScreenState extends State { } } +class _UpdatePreview extends StatelessWidget { + const _UpdatePreview({ + required this.activity, + required this.note, + required this.includeLocation, + required this.precision, + required this.duration, + }); + + final SocialShareActivity activity; + final String note; + final bool includeLocation; + final LocationSharePrecision precision; + final SocialShareDuration duration; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final normalizedNote = SocialShareMessage.normalizeNote(note); + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.visibility_outlined, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Text('分享預覽', style: theme.textTheme.labelLarge), + ], + ), + const SizedBox(height: 10), + Text(activity.label, style: theme.textTheme.titleMedium), + if (normalizedNote.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(normalizedNote), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _PreviewChip( + icon: includeLocation + ? Icons.location_on_outlined + : Icons.location_off_outlined, + label: includeLocation ? precision.label : '不分享位置', + ), + _PreviewChip(icon: Icons.timer_outlined, label: duration.label), + ], + ), + ], + ), + ), + ); + } +} + +class _PreviewChip extends StatelessWidget { + const _PreviewChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Chip( + avatar: Icon(icon, size: 16), + label: Text(label), + visualDensity: VisualDensity.compact, + ); + } +} + class _LoginRequiredCard extends StatelessWidget { const _LoginRequiredCard({required this.theme}); @@ -218,7 +425,7 @@ class _PrivacyCard extends StatelessWidget { SizedBox(width: 12), Expanded( child: Text( - '安全提醒:位置連結可以被轉傳,請確認收件人可信任。這個功能目前是一次性分享,不是好友即時定位或背景追蹤。', + '安全提醒:只有按下分享時才會取得位置,不會背景追蹤。建議查看期限不會自動刪除其他 App 中的內容,位置連結也可能被轉傳,請只分享給信任的人。', ), ), ], diff --git a/pubspec.lock b/pubspec.lock index 5d08997..006c364 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -66,7 +66,7 @@ packages: source: hosted version: "1.2.3" characters: - dependency: transitive + dependency: "direct main" description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b diff --git a/pubspec.yaml b/pubspec.yaml index 0c9991a..b05b495 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + characters: ^1.4.1 flutter: sdk: flutter flutter_localizations: diff --git a/test/location_share_test.dart b/test/location_share_test.dart index 67ccd37..96f24c6 100644 --- a/test/location_share_test.dart +++ b/test/location_share_test.dart @@ -44,4 +44,59 @@ void main() { expect(text, contains('一次性分享')); expect(text, contains('100 公尺')); }); + + test('social update can share information without requesting a location', () { + final text = SocialShareMessage.compose( + displayName: 'Steven', + activity: SocialShareActivity.waiting, + duration: SocialShareDuration.fifteenMinutes, + note: ' 我在捷運站 2 號出口等你 ', + createdAt: DateTime(2026, 9, 23, 9, 30), + ); + + expect(text, contains('Steven 的近況|正在等車')); + expect(text, contains('我在捷運站 2 號出口等你')); + expect(text, contains('建議查看至:2026/09/23 09:45')); + expect(text, isNot(contains('google.com/maps'))); + }); + + test('social update includes privacy-labelled approximate location', () { + final text = SocialShareMessage.compose( + displayName: '', + activity: SocialShareActivity.arriving, + duration: SocialShareDuration.oneHour, + position: _position(), + createdAt: DateTime(2026, 9, 23, 9, 30), + ); + + expect(text, contains('我的近況|即將抵達')); + expect(text, contains('位置(概略位置)')); + expect(text, contains('25.033%2C121.565')); + expect(text, contains('無法遠端撤回')); + }); + + test('social update limits long notes', () { + final note = List.filled(140, 'a').join(); + final text = SocialShareMessage.compose( + displayName: 'Steven', + activity: SocialShareActivity.riding, + duration: SocialShareDuration.oneDay, + note: note, + createdAt: DateTime(2026, 9, 23), + ); + + expect(text, contains(List.filled(120, 'a').join())); + expect(text, isNot(contains(List.filled(121, 'a').join()))); + }); + + test('social update preserves emoji grapheme at the character limit', () { + const familyEmoji = '👨‍👩‍👧‍👦'; + final note = '${List.filled(119, 'a').join()}$familyEmoji'; + + final normalized = SocialShareMessage.normalizeNote(note); + + expect(normalized, note); + expect(normalized, endsWith(familyEmoji)); + expect(normalized.runes, isNot(contains(0xFFFD))); + }); }