From a0b2275beae3609165c5da628efbebecea55b219 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 20 May 2025 15:48:51 +0100 Subject: [PATCH] feat: setup intl closes #29 --- lib/i18n/en.i18n.json | 118 ++++++++++ lib/i18n/strings.g.dart | 168 +++++++++++++++ lib/i18n/strings_en.g.dart | 308 +++++++++++++++++++++++++++ lib/main.dart | 7 +- lib/widgets/avatar.dart | 1 - lib/widgets/avatar_upload.dart | 3 +- lib/widgets/button_follow.dart | 5 +- lib/widgets/category_top_zapped.dart | 3 +- lib/widgets/chat.dart | 31 +-- lib/widgets/chat_badge.dart | 3 +- lib/widgets/chat_raid.dart | 177 +++++---------- lib/widgets/chat_timeout.dart | 52 +++-- lib/widgets/chat_write.dart | 9 +- lib/widgets/chat_zap.dart | 37 ++-- lib/widgets/countdown.dart | 76 +++++++ lib/widgets/goal.dart | 7 +- lib/widgets/header.dart | 6 +- lib/widgets/mute_button.dart | 3 +- lib/widgets/note_embed.dart | 66 +++--- lib/widgets/stream_grid.dart | 17 +- lib/widgets/stream_info.dart | 3 +- lib/widgets/zap.dart | 42 ++-- pubspec.lock | 63 +++++- pubspec.yaml | 20 +- 24 files changed, 955 insertions(+), 270 deletions(-) create mode 100644 lib/i18n/en.i18n.json create mode 100644 lib/i18n/strings.g.dart create mode 100644 lib/i18n/strings_en.g.dart create mode 100644 lib/widgets/countdown.dart diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json new file mode 100644 index 0000000..fb6462c --- /dev/null +++ b/lib/i18n/en.i18n.json @@ -0,0 +1,118 @@ +{ + "upload_avatar": "Upload Avatar", + "@upload_avatar": { + "description": "Text prompting user to hit avatar placeholder to begin upload" + }, + "most_zapped_streamers": "Most Zapped Streamers", + "@most_zapped_streamers": { + "description": "Heading over listed top streamers by zaps" + }, + "no_user_found": "No user found", + "@no_user_found": { + "description": "No user found when searching" + }, + "anon": "Anon", + "@anon": { + "description": "An anonymous user" + }, + "stream": { + "chat": { + "disabled": "CHAT DISABLED", + "disabled_timeout": "Timeout expires: $time", + "timeout(rich)": "$mod timed out $user for $time", + "@timeout": { + "description": "Chat message showing timeout events" + }, + "ended": "STREAM ENDED", + "@ended": { + "description": "Stream ended footer at bottom of chat" + }, + "zap(rich)": "$user zapped $amount sats", + "@zap": { + "description": "Chat message showing stream zaps" + }, + "write": { + "label": "Write message", + "@label": { + "description": "Label on the chat message input box" + }, + "no_signer": "Can't write messages with npub login", + "@no_signer": { + "description": "Chat input message shown when the user is logged in only with pubkey" + }, + "login": "Please login to send messages", + "@login": { + "description": "Chat input message shown when the user is logged out" + } + }, + "badge": { + "awarded_to": "Awarded to:", + "@awarded_to": { + "description": "Heading over list of users who are awarded a badge" + } + }, + "raid": { + "to": "RAIDING $name", + "@to": { + "description": "Chat raid message to another stream" + }, + "from": "RAID FROM $name", + "@from": { + "description": "Chat raid message from another stream" + }, + "countdown": "Raiding in $time", + "@countdown": { + "description": "Countdown timer for auto-raiding" + } + } + } + }, + "goal": { + "title": "Goal: $amount", + "remaining": "Remaining: $amount", + "complete": "COMPLETE" + }, + "button": { + "login": "Login", + "@login": { + "description": "Button text for the login button" + }, + "follow": "Follow", + "@follow": { + "description": "Button text for the follow button" + }, + "unfollow": "Unfollow", + "@unfollow": { + "description": "Button text for the unfollow button" + }, + "mute": "Mute", + "unmute": "Unmute", + "share": "Share" + }, + "embed": { + "article_by": "Article by $name", + "note_by": "Note by $name", + "live_stream_by": "Live stream by $name" + }, + "stream_list": { + "following": "Following", + "live": "Live", + "planned": "Planned", + "ended": "Ended" + }, + "zap": { + "title": "Zap $name", + "custom_amount": "Custom Amount", + "confirm": "Confirm", + "comment": "Comment", + "button_zap_ready": "Zap $amount sats", + "button_zap": "Zap", + "button_open_wallet": "Open in Wallet", + "copy": "Copied to clipboard", + "error": { + "invalid_custom_amount": "Invalid custom amount", + "no_wallet": "No lightning wallet installed", + "no_lud16": "No lightning address found" + } + } +} \ No newline at end of file diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart new file mode 100644 index 0000000..c3d04ae --- /dev/null +++ b/lib/i18n/strings.g.dart @@ -0,0 +1,168 @@ +/// Generated file. Do not edit. +/// +/// Source: lib/i18n +/// To regenerate, run: `dart run slang` +/// +/// Locales: 1 +/// Strings: 43 +/// +/// Built on 2025-05-20 at 14:43 UTC + +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:slang/generated.dart'; +import 'package:slang_flutter/slang_flutter.dart'; +export 'package:slang_flutter/slang_flutter.dart'; + +part 'strings_en.g.dart'; + +/// Supported locales. +/// +/// Usage: +/// - LocaleSettings.setLocale(AppLocale.en) // set locale +/// - Locale locale = AppLocale.en.flutterLocale // get flutter locale from enum +/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check +enum AppLocale with BaseAppLocale { + en(languageCode: 'en'); + + const AppLocale({ + required this.languageCode, + this.scriptCode, // ignore: unused_element, unused_element_parameter + this.countryCode, // ignore: unused_element, unused_element_parameter + }); + + @override final String languageCode; + @override final String? scriptCode; + @override final String? countryCode; + + @override + Future build({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + }) async { + switch (this) { + case AppLocale.en: + return TranslationsEn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + } + } + + @override + Translations buildSync({ + Map? overrides, + PluralResolver? cardinalResolver, + PluralResolver? ordinalResolver, + }) { + switch (this) { + case AppLocale.en: + return TranslationsEn( + overrides: overrides, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + } + } + + /// Gets current instance managed by [LocaleSettings]. + Translations get translations => LocaleSettings.instance.getTranslations(this); +} + +/// Method A: Simple +/// +/// No rebuild after locale change. +/// Translation happens during initialization of the widget (call of t). +/// Configurable via 'translate_var'. +/// +/// Usage: +/// String a = t.someKey.anotherKey; +/// String b = t['someKey.anotherKey']; // Only for edge cases! +Translations get t => LocaleSettings.instance.currentTranslations; + +/// Method B: Advanced +/// +/// All widgets using this method will trigger a rebuild when locale changes. +/// Use this if you have e.g. a settings page where the user can select the locale during runtime. +/// +/// Step 1: +/// wrap your App with +/// TranslationProvider( +/// child: MyApp() +/// ); +/// +/// Step 2: +/// final t = Translations.of(context); // Get t variable. +/// String a = t.someKey.anotherKey; // Use t variable. +/// String b = t['someKey.anotherKey']; // Only for edge cases! +class TranslationProvider extends BaseTranslationProvider { + TranslationProvider({required super.child}) : super(settings: LocaleSettings.instance); + + static InheritedLocaleData of(BuildContext context) => InheritedLocaleData.of(context); +} + +/// Method B shorthand via [BuildContext] extension method. +/// Configurable via 'translate_var'. +/// +/// Usage (e.g. in a widget's build method): +/// context.t.someKey.anotherKey +extension BuildContextTranslationsExtension on BuildContext { + Translations get t => TranslationProvider.of(this).translations; +} + +/// Manages all translation instances and the current locale +class LocaleSettings extends BaseFlutterLocaleSettings { + LocaleSettings._() : super( + utils: AppLocaleUtils.instance, + lazy: true, + ); + + static final instance = LocaleSettings._(); + + // static aliases (checkout base methods for documentation) + static AppLocale get currentLocale => instance.currentLocale; + static Stream getLocaleStream() => instance.getLocaleStream(); + static Future setLocale(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocale(locale, listenToDeviceLocale: listenToDeviceLocale); + static Future setLocaleRaw(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRaw(rawLocale, listenToDeviceLocale: listenToDeviceLocale); + static Future useDeviceLocale() => instance.useDeviceLocale(); + static Future setPluralResolver({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolver( + language: language, + locale: locale, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); + + // synchronous versions + static AppLocale setLocaleSync(AppLocale locale, {bool? listenToDeviceLocale = false}) => instance.setLocaleSync(locale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale setLocaleRawSync(String rawLocale, {bool? listenToDeviceLocale = false}) => instance.setLocaleRawSync(rawLocale, listenToDeviceLocale: listenToDeviceLocale); + static AppLocale useDeviceLocaleSync() => instance.useDeviceLocaleSync(); + static void setPluralResolverSync({String? language, AppLocale? locale, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) => instance.setPluralResolverSync( + language: language, + locale: locale, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ); +} + +/// Provides utility functions without any side effects. +class AppLocaleUtils extends BaseAppLocaleUtils { + AppLocaleUtils._() : super( + baseLocale: AppLocale.en, + locales: AppLocale.values, + ); + + static final instance = AppLocaleUtils._(); + + // static aliases (checkout base methods for documentation) + static AppLocale parse(String rawLocale) => instance.parse(rawLocale); + static AppLocale parseLocaleParts({required String languageCode, String? scriptCode, String? countryCode}) => instance.parseLocaleParts(languageCode: languageCode, scriptCode: scriptCode, countryCode: countryCode); + static AppLocale findDeviceLocale() => instance.findDeviceLocale(); + static List get supportedLocales => instance.supportedLocales; + static List get supportedLocalesRaw => instance.supportedLocalesRaw; +} diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart new file mode 100644 index 0000000..db3b088 --- /dev/null +++ b/lib/i18n/strings_en.g.dart @@ -0,0 +1,308 @@ +/// +/// Generated file. Do not edit. +/// +// coverage:ignore-file +// ignore_for_file: type=lint, unused_import + +part of 'strings.g.dart'; + +// Path: +typedef TranslationsEn = Translations; // ignore: unused_element +class Translations implements BaseTranslations { + /// Returns the current translations of the given [context]. + /// + /// Usage: + /// final t = Translations.of(context); + static Translations of(BuildContext context) => InheritedLocaleData.of(context).translations; + + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + Translations({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = meta ?? TranslationMetadata( + locale: AppLocale.en, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + dynamic operator[](String key) => $meta.getTranslation(key); + + late final Translations _root = this; // ignore: unused_field + + Translations $copyWith({TranslationMetadata? meta}) => Translations(meta: meta ?? this.$meta); + + // Translations + + /// Text prompting user to hit avatar placeholder to begin upload + String get upload_avatar => 'Upload Avatar'; + + /// Heading over listed top streamers by zaps + String get most_zapped_streamers => 'Most Zapped Streamers'; + + /// No user found when searching + String get no_user_found => 'No user found'; + + /// An anonymous user + String get anon => 'Anon'; + + late final TranslationsStreamEn stream = TranslationsStreamEn._(_root); + late final TranslationsGoalEn goal = TranslationsGoalEn._(_root); + late final TranslationsButtonEn button = TranslationsButtonEn._(_root); + late final TranslationsEmbedEn embed = TranslationsEmbedEn._(_root); + late final TranslationsStreamListEn stream_list = TranslationsStreamListEn._(_root); + late final TranslationsZapEn zap = TranslationsZapEn._(_root); +} + +// Path: stream +class TranslationsStreamEn { + TranslationsStreamEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + late final TranslationsStreamChatEn chat = TranslationsStreamChatEn._(_root); +} + +// Path: goal +class TranslationsGoalEn { + TranslationsGoalEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String title({ required Object amount}) => 'Goal: ${amount}'; + String remaining({ required Object amount}) => 'Remaining: ${amount}'; + String get complete => 'COMPLETE'; +} + +// Path: button +class TranslationsButtonEn { + TranslationsButtonEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// Button text for the login button + String get login => 'Login'; + + /// Button text for the follow button + String get follow => 'Follow'; + + /// Button text for the unfollow button + String get unfollow => 'Unfollow'; + + String get mute => 'Mute'; + String get unmute => 'Unmute'; + String get share => 'Share'; +} + +// Path: embed +class TranslationsEmbedEn { + TranslationsEmbedEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String article_by({ required Object name}) => 'Article by ${name}'; + String note_by({ required Object name}) => 'Note by ${name}'; + String live_stream_by({ required Object name}) => 'Live stream by ${name}'; +} + +// Path: stream_list +class TranslationsStreamListEn { + TranslationsStreamListEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get following => 'Following'; + String get live => 'Live'; + String get planned => 'Planned'; + String get ended => 'Ended'; +} + +// Path: zap +class TranslationsZapEn { + TranslationsZapEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String title({ required Object name}) => 'Zap ${name}'; + String get custom_amount => 'Custom Amount'; + String get confirm => 'Confirm'; + String get comment => 'Comment'; + String button_zap_ready({ required Object amount}) => 'Zap ${amount} sats'; + String get button_zap => 'Zap'; + String get button_open_wallet => 'Open in Wallet'; + String get copy => 'Copied to clipboard'; + late final TranslationsZapErrorEn error = TranslationsZapErrorEn._(_root); +} + +// Path: stream.chat +class TranslationsStreamChatEn { + TranslationsStreamChatEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get disabled => 'CHAT DISABLED'; + String disabled_timeout({ required Object time}) => 'Timeout expires: ${time}'; + + /// Chat message showing timeout events + TextSpan timeout({ required InlineSpan mod, required InlineSpan user, required InlineSpan time, TextStyle? style, GestureRecognizer? recognizer}) => TextSpan(children: [ + mod, + const TextSpan(text: ' timed out '), + user, + const TextSpan(text: ' for '), + time, + ], style: style, recognizer: recognizer); + + /// Stream ended footer at bottom of chat + String get ended => 'STREAM ENDED'; + + /// Chat message showing stream zaps + TextSpan zap({ required InlineSpan user, required InlineSpan amount, TextStyle? style, GestureRecognizer? recognizer}) => TextSpan(children: [ + user, + const TextSpan(text: ' zapped '), + amount, + const TextSpan(text: ' sats'), + ], style: style, recognizer: recognizer); + + late final TranslationsStreamChatWriteEn write = TranslationsStreamChatWriteEn._(_root); + late final TranslationsStreamChatBadgeEn badge = TranslationsStreamChatBadgeEn._(_root); + late final TranslationsStreamChatRaidEn raid = TranslationsStreamChatRaidEn._(_root); +} + +// Path: zap.error +class TranslationsZapErrorEn { + TranslationsZapErrorEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get invalid_custom_amount => 'Invalid custom amount'; + String get no_wallet => 'No lightning wallet installed'; + String get no_lud16 => 'No lightning address found'; +} + +// Path: stream.chat.write +class TranslationsStreamChatWriteEn { + TranslationsStreamChatWriteEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// Label on the chat message input box + String get label => 'Write message'; + + /// Chat input message shown when the user is logged in only with pubkey + String get no_signer => 'Can\'t write messages with npub login'; + + /// Chat input message shown when the user is logged out + String get login => 'Please login to send messages'; +} + +// Path: stream.chat.badge +class TranslationsStreamChatBadgeEn { + TranslationsStreamChatBadgeEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// Heading over list of users who are awarded a badge + String get awarded_to => 'Awarded to:'; +} + +// Path: stream.chat.raid +class TranslationsStreamChatRaidEn { + TranslationsStreamChatRaidEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// Chat raid message to another stream + String to({ required Object name}) => 'RAIDING ${name}'; + + /// Chat raid message from another stream + String from({ required Object name}) => 'RAID FROM ${name}'; + + /// Countdown timer for auto-raiding + String countdown({ required Object time}) => 'Raiding in ${time}'; +} + +/// Flat map(s) containing all translations. +/// Only for edge cases! For simple maps, use the map function of this library. +extension on Translations { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'upload_avatar': return 'Upload Avatar'; + case 'most_zapped_streamers': return 'Most Zapped Streamers'; + case 'no_user_found': return 'No user found'; + case 'anon': return 'Anon'; + case 'stream.chat.disabled': return 'CHAT DISABLED'; + case 'stream.chat.disabled_timeout': return ({ required Object time}) => 'Timeout expires: ${time}'; + case 'stream.chat.timeout': return ({ required InlineSpan mod, required InlineSpan user, required InlineSpan time, TextStyle? style, GestureRecognizer? recognizer}) => TextSpan(children: [ + mod, + const TextSpan(text: ' timed out '), + user, + const TextSpan(text: ' for '), + time, + ], style: style, recognizer: recognizer); + case 'stream.chat.ended': return 'STREAM ENDED'; + case 'stream.chat.zap': return ({ required InlineSpan user, required InlineSpan amount, TextStyle? style, GestureRecognizer? recognizer}) => TextSpan(children: [ + user, + const TextSpan(text: ' zapped '), + amount, + const TextSpan(text: ' sats'), + ], style: style, recognizer: recognizer); + case 'stream.chat.write.label': return 'Write message'; + case 'stream.chat.write.no_signer': return 'Can\'t write messages with npub login'; + case 'stream.chat.write.login': return 'Please login to send messages'; + case 'stream.chat.badge.awarded_to': return 'Awarded to:'; + case 'stream.chat.raid.to': return ({ required Object name}) => 'RAIDING ${name}'; + case 'stream.chat.raid.from': return ({ required Object name}) => 'RAID FROM ${name}'; + case 'stream.chat.raid.countdown': return ({ required Object time}) => 'Raiding in ${time}'; + case 'goal.title': return ({ required Object amount}) => 'Goal: ${amount}'; + case 'goal.remaining': return ({ required Object amount}) => 'Remaining: ${amount}'; + case 'goal.complete': return 'COMPLETE'; + case 'button.login': return 'Login'; + case 'button.follow': return 'Follow'; + case 'button.unfollow': return 'Unfollow'; + case 'button.mute': return 'Mute'; + case 'button.unmute': return 'Unmute'; + case 'button.share': return 'Share'; + case 'embed.article_by': return ({ required Object name}) => 'Article by ${name}'; + case 'embed.note_by': return ({ required Object name}) => 'Note by ${name}'; + case 'embed.live_stream_by': return ({ required Object name}) => 'Live stream by ${name}'; + case 'stream_list.following': return 'Following'; + case 'stream_list.live': return 'Live'; + case 'stream_list.planned': return 'Planned'; + case 'stream_list.ended': return 'Ended'; + case 'zap.title': return ({ required Object name}) => 'Zap ${name}'; + case 'zap.custom_amount': return 'Custom Amount'; + case 'zap.confirm': return 'Confirm'; + case 'zap.comment': return 'Comment'; + case 'zap.button_zap_ready': return ({ required Object amount}) => 'Zap ${amount} sats'; + case 'zap.button_zap': return 'Zap'; + case 'zap.button_open_wallet': return 'Open in Wallet'; + case 'zap.copy': return 'Copied to clipboard'; + case 'zap.error.invalid_custom_amount': return 'Invalid custom amount'; + case 'zap.error.no_wallet': return 'No lightning wallet installed'; + case 'zap.error.no_lud16': return 'No lightning address found'; + default: return null; + } + } +} + diff --git a/lib/main.dart b/lib/main.dart index 7df06da..f7d1dfb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,13 @@ import 'package:amberflutter/amberflutter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:go_router/go_router.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_amber/ndk_amber.dart'; import 'package:ndk_objectbox/ndk_objectbox.dart'; import 'package:ndk_rust_verifier/ndk_rust_verifier.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/pages/category.dart'; import 'package:zap_stream_flutter/pages/hashtag.dart'; import 'package:zap_stream_flutter/pages/login.dart'; @@ -55,6 +57,7 @@ final RouteObserver> routeObserver = Future main() async { WidgetsFlutterBinding.ensureInitialized(); + LocaleSettings.useDeviceLocale(); // reload / cache login data loginData.addListener(() { @@ -89,6 +92,9 @@ Future main() async { runApp( MaterialApp.router( + title: "zap.stream", + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: GlobalMaterialLocalizations.delegates, theme: ThemeData.localize( ThemeData(colorScheme: ColorScheme.dark(), highlightColor: PRIMARY_1), TextTheme(), @@ -104,7 +110,6 @@ Future main() async { routes: [ GoRoute(path: "/", builder: (ctx, state) => HomePage()), ShellRoute( - observers: [routeObserver], builder: (context, state, child) { return Container( margin: EdgeInsets.only(top: 50), diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index 9d16c6f..ea8d290 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; import 'package:zap_stream_flutter/imgproxy.dart'; diff --git a/lib/widgets/avatar_upload.dart b/lib/widgets/avatar_upload.dart index 38b82ed..b1b76fb 100644 --- a/lib/widgets/avatar_upload.dart +++ b/lib/widgets/avatar_upload.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; @@ -90,7 +91,7 @@ class _AvatarUpload extends State { child: _loading ? CircularProgressIndicator() - : Text("Upload Avatar"), + : Text(t.upload_avatar), ) : CachedNetworkImage(imageUrl: _avatar!), ), diff --git a/lib/widgets/button_follow.dart b/lib/widgets/button_follow.dart index 6df510d..3ccae64 100644 --- a/lib/widgets/button_follow.dart +++ b/lib/widgets/button_follow.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/widgets/button.dart'; @@ -49,7 +50,9 @@ class FollowButton extends StatelessWidget { size: 16, ), Text( - isFollowing ? "Unfollow" : "Follow", + isFollowing + ? t.button.unfollow + : t.button.follow, style: TextStyle(fontWeight: FontWeight.bold), ), ], diff --git a/lib/widgets/category_top_zapped.dart b/lib/widgets/category_top_zapped.dart index 9110c3e..8bec7c2 100644 --- a/lib/widgets/category_top_zapped.dart +++ b/lib/widgets/category_top_zapped.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/rx_filter.dart'; import 'package:zap_stream_flutter/theme.dart'; @@ -28,7 +29,7 @@ class CategoryTopZapped extends StatelessWidget { alignment: PlaceholderAlignment.middle, ), TextSpan( - text: " Most Zapped Streamers", + text: " ${t.most_zapped_streamers}", style: TextStyle(color: LAYER_4, fontWeight: FontWeight.w500), ), ], diff --git a/lib/widgets/chat.dart b/lib/widgets/chat.dart index 8de7017..4bf1132 100644 --- a/lib/widgets/chat.dart +++ b/lib/widgets/chat.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/rx_filter.dart'; import 'package:zap_stream_flutter/theme.dart'; @@ -11,6 +12,7 @@ import 'package:zap_stream_flutter/widgets/chat_raid.dart'; import 'package:zap_stream_flutter/widgets/chat_timeout.dart'; import 'package:zap_stream_flutter/widgets/chat_write.dart'; import 'package:zap_stream_flutter/widgets/chat_zap.dart'; +import 'package:zap_stream_flutter/widgets/countdown.dart'; import 'package:zap_stream_flutter/widgets/goal.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; @@ -75,7 +77,9 @@ class ChatWidget extends StatelessWidget { 9735 => ZapReceipt.fromEvent(e).sender ?? e.pubKey, _ => e.pubKey, }; - return moderators.contains(author) || // cant mute self or host + return moderators.contains( + author, + ) || // cant mute self or host !mutedPubkeys.contains(author); }) // filter events that are created before stream start time @@ -159,7 +163,7 @@ class ChatWidget extends StatelessWidget { color: PRIMARY_1, ), child: Text( - "STREAM ENDED", + t.stream.chat.ended, style: TextStyle(fontWeight: FontWeight.bold), ), ), @@ -181,19 +185,18 @@ class ChatWidget extends StatelessWidget { decoration: BoxDecoration(color: WARNING), child: Column( children: [ - Text("CHAT DISABLED", style: TextStyle(fontWeight: FontWeight.bold)), + Text( + t.stream.chat.disabled, + style: TextStyle(fontWeight: FontWeight.bold), + ), if (timeoutEvent != null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("Timeout expires: "), - CountdownTimer( - onTrigger: () => {}, - triggerAt: DateTime.fromMillisecondsSinceEpoch( - int.parse(timeoutEvent.getFirstTag("expiration")!) * 1000, - ), - ), - ], + CountdownTimer( + onTrigger: () => {}, + format: (time) => t.stream.chat.disabled_timeout(time: time), + style: TextStyle(color: LAYER_5), + triggerAt: DateTime.fromMillisecondsSinceEpoch( + int.parse(timeoutEvent.getFirstTag("expiration")!) * 1000, + ), ), ], ), diff --git a/lib/widgets/chat_badge.dart b/lib/widgets/chat_badge.dart index 9c547e1..7ee6d35 100644 --- a/lib/widgets/chat_badge.dart +++ b/lib/widgets/chat_badge.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:ndk/entities.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/imgproxy.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; @@ -60,7 +61,7 @@ class ChatBadgeAwardWidget extends StatelessWidget { ], ), Text( - "Awarded to: ", + "${t.stream.chat.badge.awarded_to} ", style: TextStyle(fontWeight: FontWeight.w500), ), ...event diff --git a/lib/widgets/chat_raid.dart b/lib/widgets/chat_raid.dart index 1bab7b3..239348f 100644 --- a/lib/widgets/chat_raid.dart +++ b/lib/widgets/chat_raid.dart @@ -1,11 +1,12 @@ import 'package:collection/collection.dart'; -import 'package:duration/duration.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/countdown.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; class ChatRaidMessage extends StatefulWidget { @@ -74,64 +75,57 @@ class __ChatRaidMessage extends State final otherStreamEvent = StreamEvent(otherStream); return Column( children: [ - RichText( - text: TextSpan( - style: TextStyle(fontWeight: FontWeight.bold), - children: [ - TextSpan(text: _isRaiding ? "RAIDING " : "RAID FROM "), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ProfileLoaderWidget(otherStreamEvent.info.host, ( - ctx, - profile, - ) { - return Text( - ProfileNameWidget.nameFromProfile( - profile.data ?? - Metadata(pubKey: otherStreamEvent.info.host), - ).toUpperCase(), - style: TextStyle(fontWeight: FontWeight.bold), - ); - }), - ), - if (_raidingAt == null) - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - onTap: () { - context.go( - "/e/${otherStreamEvent.link}", - extra: otherStreamEvent, - ); - }, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.open_in_new, size: 15), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ProfileLoaderWidget(otherStreamEvent.info.host, ( + ctx, + profile, + ) { + final otherMeta = + profile.data ?? + Metadata(pubKey: otherStreamEvent.info.host); + return Text( + _isRaiding + ? t.stream.chat.raid.to( + name: + ProfileNameWidget.nameFromProfile( + otherMeta, + ).toUpperCase(), + ) + : t.stream.chat.raid.from( + name: + ProfileNameWidget.nameFromProfile( + otherMeta, + ).toUpperCase(), ), - ), - ), - ], - ), + style: TextStyle(fontWeight: FontWeight.bold), + ); + }), + GestureDetector( + onTap: () { + context.go( + "/e/${otherStreamEvent.link}", + extra: otherStreamEvent, + ); + }, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.open_in_new, size: 15), + ), + ), + ], ), if (_raidingAt != null) - RichText( - text: TextSpan( - children: [ - TextSpan(text: "Raiding in "), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: CountdownTimer( - triggerAt: _raidingAt!, - onTrigger: () { - context.go( - "/e/${otherStreamEvent.link}", - extra: otherStreamEvent, - ); - }, - ), - ), - ], - ), + CountdownTimer( + format: (time) => t.stream.chat.raid.countdown(time: time), + triggerAt: _raidingAt!, + onTrigger: () { + context.go( + "/e/${otherStreamEvent.link}", + extra: otherStreamEvent, + ); + }, ), ], ); @@ -140,74 +134,3 @@ class __ChatRaidMessage extends State ); } } - -class CountdownTimer extends StatefulWidget { - final void Function() onTrigger; - final TextStyle? style; - final DateTime triggerAt; - - const CountdownTimer({ - super.key, - required this.onTrigger, - this.style, - required this.triggerAt, - }); - - @override - createState() => _CountdownTimerState(); -} - -class _CountdownTimerState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; - bool _actionTriggered = false; - - @override - void initState() { - super.initState(); - final now = DateTime.now(); - final countdown = - widget.triggerAt.isBefore(now) - ? Duration() - : widget.triggerAt.difference(now); - - _controller = AnimationController(vsync: this, duration: countdown); - - // Create animation to track progress from 5 to 0 - _animation = Tween( - begin: countdown.inSeconds.toDouble(), - end: 0, - ).animate(_controller)..addStatusListener((status) { - if (status == AnimationStatus.completed && !_actionTriggered) { - setState(() { - _actionTriggered = true; - widget.onTrigger(); - }); - } - }); - - // Start the countdown immediately when widget is mounted - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); // Clean up the controller - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _animation, - builder: (context, child) { - final secondsLeft = _animation.value.ceil(); - return Text( - Duration(seconds: secondsLeft).pretty(abbreviated: true), - style: widget.style, - ); - }, - ); - } -} diff --git a/lib/widgets/chat_timeout.dart b/lib/widgets/chat_timeout.dart index ca46902..7eb2e30 100644 --- a/lib/widgets/chat_timeout.dart +++ b/lib/widgets/chat_timeout.dart @@ -1,6 +1,9 @@ +import 'package:collection/collection.dart'; import 'package:duration/duration.dart'; import 'package:flutter/widgets.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; +import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; @@ -11,32 +14,43 @@ class ChatTimeoutWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final pTags = timeout.pTags; final duration = double.parse(timeout.getFirstTag("expiration")!) - timeout.createdAt; return Container( padding: EdgeInsets.symmetric(horizontal: 2, vertical: 4), - child: RichText( - text: TextSpan( - style: TextStyle(color: LAYER_5), - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ProfileNameWidget.pubkey(timeout.pubKey), - ), - TextSpan(text: " timed out "), - ...pTags.map( - (p) => WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ProfileNameWidget.pubkey(p), + child: FutureBuilder( + future: ndk.metadata.loadMetadatas([ + timeout.pubKey, + ...timeout.pTags, + ], null), + builder: (context, state) { + final modProfile = + state.data?.firstWhereOrNull((p) => p.pubKey == timeout.pubKey) ?? + Metadata(pubKey: timeout.pubKey); + final userProfiles = timeout.pTags.map( + (p) => + state.data?.firstWhereOrNull((x) => x.pubKey == p) ?? + Metadata(pubKey: p), + ); + + return Text.rich( + style: TextStyle(color: LAYER_5), + t.stream.chat.timeout( + mod: TextSpan( + text: ProfileNameWidget.nameFromProfile(modProfile), + ), + user: TextSpan( + text: userProfiles + .map((p) => ProfileNameWidget.nameFromProfile(p)) + .join(", "), + ), + time: TextSpan( + text: Duration(seconds: duration.floor()).pretty(), ), ), - TextSpan( - text: " for ${Duration(seconds: duration.toInt()).pretty()}", - ), - ], - ), + ); + }, ), ); } diff --git a/lib/widgets/chat_write.dart b/lib/widgets/chat_write.dart index d377b60..b285f79 100644 --- a/lib/widgets/chat_write.dart +++ b/lib/widgets/chat_write.dart @@ -3,6 +3,7 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -90,7 +91,7 @@ class __WriteMessageWidget extends State { future: ndkCache.searchMetadatas(search, 5), builder: (context, state) { if (state.data?.isEmpty ?? true) { - return Text("No user found"); + return Text(t.no_user_found); } return Column( @@ -223,7 +224,7 @@ class __WriteMessageWidget extends State { controller: _controller, onSubmitted: (_) => _sendMessage(context), decoration: InputDecoration( - labelText: "Write message", + labelText: t.stream.chat.write.label, contentPadding: EdgeInsets.symmetric(vertical: 4), labelStyle: TextStyle(color: LAYER_4, fontSize: 14), border: InputBorder.none, @@ -255,8 +256,8 @@ class __WriteMessageWidget extends State { children: [ Text( isLogin - ? "Can't write messages with npub login" - : "Please login to send messages", + ? t.stream.chat.write.no_signer + : t.stream.chat.write.login, ), ], ), diff --git a/lib/widgets/chat_zap.dart b/lib/widgets/chat_zap.dart index d508210..cbeea6c 100644 --- a/lib/widgets/chat_zap.dart +++ b/lib/widgets/chat_zap.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/widgets/avatar.dart'; @@ -24,14 +25,14 @@ class ChatZapWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _zapperRowZap(parsed), + _zapperRowZap(context, parsed), if (parsed.comment?.isNotEmpty ?? false) Text(parsed.comment!), ], ), ); } - Widget _zapperRowZap(ZapReceipt parsed) { + Widget _zapperRowZap(BuildContext context, ZapReceipt parsed) { if (parsed.sender != null) { return ProfileLoaderWidget(parsed.sender!, (ctx, state) { final name = ProfileNameWidget.nameFromProfile( @@ -40,35 +41,23 @@ class ChatZapWidget extends StatelessWidget { return _zapperRow(name, parsed.amountSats ?? 0, state.data); }); } else { - return _zapperRow("Anon", parsed.amountSats ?? 0, null); + return _zapperRow(t.anon, parsed.amountSats ?? 0, null); } } Widget _zapperRow(String name, int amount, Metadata? profile) { return Row( - crossAxisAlignment: CrossAxisAlignment.end, + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + if (profile != null) AvatarWidget(profile: profile, size: 24), RichText( - text: TextSpan( - style: TextStyle(color: ZAP_1), - children: [ - WidgetSpan( - child: Icon(Icons.bolt, color: ZAP_1), - alignment: PlaceholderAlignment.middle, - ), - if (profile != null) - WidgetSpan( - child: Padding( - padding: EdgeInsets.only(right: 8), - child: AvatarWidget(profile: profile, size: 20), - ), - alignment: PlaceholderAlignment.middle, - ), - TextSpan(text: name), - TextSpan(text: " zapped ", style: TextStyle(color: FONT_COLOR)), - TextSpan(text: formatSats(amount)), - TextSpan(text: " sats", style: TextStyle(color: FONT_COLOR)), - ], + text: t.stream.chat.zap( + user: TextSpan(text: name, style: TextStyle(color: ZAP_1)), + amount: TextSpan( + text: formatSats(amount), + style: TextStyle(color: ZAP_1), + ), ), ), ], diff --git a/lib/widgets/countdown.dart b/lib/widgets/countdown.dart new file mode 100644 index 0000000..cd8d620 --- /dev/null +++ b/lib/widgets/countdown.dart @@ -0,0 +1,76 @@ +import 'package:duration/duration.dart'; +import 'package:flutter/material.dart'; + +class CountdownTimer extends StatefulWidget { + final void Function() onTrigger; + final TextStyle? style; + final DateTime triggerAt; + final String Function(String time)? format; + + const CountdownTimer({ + super.key, + required this.onTrigger, + this.style, + required this.triggerAt, + this.format, + }); + + @override + createState() => _CountdownTimerState(); +} + +class _CountdownTimerState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + bool _actionTriggered = false; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + final countdown = + widget.triggerAt.isBefore(now) + ? Duration() + : widget.triggerAt.difference(now); + + _controller = AnimationController(vsync: this, duration: countdown); + + // Create animation to track progress from 5 to 0 + _animation = Tween( + begin: countdown.inSeconds.toDouble(), + end: 0, + ).animate(_controller)..addStatusListener((status) { + if (status == AnimationStatus.completed && !_actionTriggered) { + setState(() { + _actionTriggered = true; + widget.onTrigger(); + }); + } + }); + + // Start the countdown immediately when widget is mounted + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); // Clean up the controller + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final secondsLeft = _animation.value.ceil(); + final v = Duration(seconds: secondsLeft).pretty(abbreviated: true); + return Text( + widget.format != null ? widget.format!(v) : v, + style: widget.style, + ); + }, + ); + } +} diff --git a/lib/widgets/goal.dart b/lib/widgets/goal.dart index 460ab60..994473e 100644 --- a/lib/widgets/goal.dart +++ b/lib/widgets/goal.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/rx_filter.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -50,7 +51,7 @@ class GoalWidget extends StatelessWidget { Expanded(child: Text(goal.content)), if (remaining > 0) Text( - "Remaining: ${formatSats(remaining)}", + t.goal.remaining(amount: formatSats(remaining)), style: TextStyle(fontSize: 10, color: LAYER_5), ), ], @@ -76,7 +77,7 @@ class GoalWidget extends StatelessWidget { Positioned( right: 2, child: Text( - "Goal: ${formatSats((max / 1000).toInt())}", + t.goal.title(amount: formatSats((max / 1000).floor())), style: TextStyle( fontSize: 8, fontWeight: FontWeight.bold, @@ -86,7 +87,7 @@ class GoalWidget extends StatelessWidget { if (remaining == 0) Center( child: Text( - "COMPLETE", + t.goal.complete, style: TextStyle( color: LAYER_0, fontSize: 8, diff --git a/lib/widgets/header.dart b/lib/widgets/header.dart index 2e62c3f..e6c80a5 100644 --- a/lib/widgets/header.dart +++ b/lib/widgets/header.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/widgets/avatar.dart'; @@ -58,7 +59,10 @@ class LoginButtonWidget extends StatelessWidget { ), child: Row( spacing: 8, - children: [Text("Login"), Icon(Icons.login, size: 16)], + children: [ + Text(t.button.login), + Icon(Icons.login, size: 16), + ], ), ), ); diff --git a/lib/widgets/mute_button.dart b/lib/widgets/mute_button.dart index 334efae..e8cb7db 100644 --- a/lib/widgets/mute_button.dart +++ b/lib/widgets/mute_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:ndk/domain_layer/entities/nip_51_list.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/widgets/button.dart'; @@ -32,7 +33,7 @@ class MuteButton extends StatelessWidget { final isMuted = mutes.contains(pubkey); return BasicButton( Text( - isMuted ? "Unmute" : "Mute", + isMuted ? t.button.unmute : t.button.mute, style: TextStyle( color: Color.fromARGB(255, 0, 0, 0), fontWeight: FontWeight.bold, diff --git a/lib/widgets/note_embed.dart b/lib/widgets/note_embed.dart index 46b0381..12756b6 100644 --- a/lib/widgets/note_embed.dart +++ b/lib/widgets/note_embed.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/rx_filter.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -22,44 +23,45 @@ class NoteEmbedWidget extends StatelessWidget { filters: [entity.toFilter()], builder: (context, data) { final note = data != null && data.isNotEmpty ? data.first : null; + if (note == null) return SizedBox.shrink(); + + final author = switch (note.kind) { + 30_311 => StreamEvent(note).info.host, + _ => note.pubKey, + }; return PillWidget( onTap: () { - if (note != null) { - // redirect to the stream if its a live stream link - if (note.kind == 30_311) { - context.push("/e/$link", extra: StreamEvent(note)); - return; - } - showModalBottomSheet( - context: context, - builder: (context) { - return SingleChildScrollView(child: _NotePreview(note: note)); - }, - ); + // redirect to the stream if its a live stream link + if (note.kind == 30_311) { + context.push("/e/$link", extra: StreamEvent(note)); + return; } + showModalBottomSheet( + context: context, + builder: (context) { + return SingleChildScrollView(child: _NotePreview(note: note)); + }, + ); }, color: LAYER_3, - child: RichText( - text: TextSpan( - children: [ - WidgetSpan(child: Icon(Icons.link, size: 16)), - TextSpan( - text: switch (entity.kind) { - 30_023 => " Article by ", - 30_311 => " Live Stream by ", - _ => " Note by ", - }, - ), - if (note?.pubKey != null) - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: ProfileNameWidget.pubkey(switch (note!.kind) { - 30_311 => StreamEvent(note).info.host, - _ => note.pubKey, - }, linkToProfile: false), + child: Row( + children: [ + Icon(Icons.link, size: 16), + ProfileLoaderWidget(author, (context, state) { + final profile = state.data ?? Metadata(pubKey: note.pubKey); + return Text(switch (entity.kind) { + 30_023 => t.embed.article_by( + name: ProfileNameWidget.nameFromProfile(profile), ), - ], - ), + 30_311 => t.embed.live_stream_by( + name: ProfileNameWidget.nameFromProfile(profile), + ), + _ => t.embed.note_by( + name: ProfileNameWidget.nameFromProfile(profile), + ), + }); + }), + ], ), ); }, diff --git a/lib/widgets/stream_grid.dart b/lib/widgets/stream_grid.dart index e0e1bf7..a0ddf78 100644 --- a/lib/widgets/stream_grid.dart +++ b/lib/widgets/stream_grid.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -54,13 +55,21 @@ class StreamGrid extends StatelessWidget { spacing: 16, children: [ if (followsLive.isNotEmpty) - _streamGroup(context, "Following", followsLive.toList()), + _streamGroup( + context, + t.stream_list.following, + followsLive.toList(), + ), if (showLive && liveNotFollowing.isNotEmpty) - _streamGroup(context, "Live", liveNotFollowing.toList()), + _streamGroup( + context, + t.stream_list.live, + liveNotFollowing.toList(), + ), if (showPlanned && planned.isNotEmpty) - _streamGroup(context, "Planned", planned.toList()), + _streamGroup(context, t.stream_list.planned, planned.toList()), if (showEnded && ended.isNotEmpty) - _streamGroup(context, "Ended", ended.toList()), + _streamGroup(context, t.stream_list.ended, ended.toList()), ], ); }, diff --git a/lib/widgets/stream_info.dart b/lib/widgets/stream_info.dart index 36f66c3..5aa0a32 100644 --- a/lib/widgets/stream_info.dart +++ b/lib/widgets/stream_info.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -45,7 +46,7 @@ class StreamInfoWidget extends StatelessWidget { }, ), BasicButton.text( - "Share", + t.button.share, icon: Icon(Icons.share, size: 16), onTap: () { SharePlus.instance.share( diff --git a/lib/widgets/zap.dart b/lib/widgets/zap.dart index 9d98111..2cbc023 100644 --- a/lib/widgets/zap.dart +++ b/lib/widgets/zap.dart @@ -8,6 +8,7 @@ import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; import 'package:ndk/ndk.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -59,20 +60,13 @@ class _ZapWidget extends State { child: Column( spacing: 10, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 5, - children: [ - Text( - "Zap", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ProfileNameWidget.pubkey( - widget.pubkey, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], - ), + ProfileLoaderWidget(widget.pubkey, (context, state) { + final profile = state.data ?? Metadata(pubKey: widget.pubkey); + return Text( + t.zap.title(name: ProfileNameWidget.nameFromProfile(profile)), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ); + }), if (_pr == null && !_loading) ..._inputs(), if (_pr != null) ..._invoice(context), if (_loading) CircularProgressIndicator(), @@ -102,11 +96,11 @@ class _ZapWidget extends State { controller: _customAmount, focusNode: _customAmountFocus, keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: "Custom Amount"), + decoration: InputDecoration(labelText: t.zap.custom_amount), ), ), BasicButton.text( - "Confirm", + t.zap.confirm, onTap: () { final newAmount = int.tryParse(_customAmount.text); if (newAmount != null) { @@ -117,7 +111,7 @@ class _ZapWidget extends State { }); } else { setState(() { - _error = "Invalid custom amount"; + _error = t.zap.error.invalid_custom_amount; _amount = null; }); } @@ -127,10 +121,12 @@ class _ZapWidget extends State { ), TextFormField( controller: _comment, - decoration: InputDecoration(labelText: "Comment"), + decoration: InputDecoration(labelText: t.zap.comment), ), BasicButton.text( - _amount != null ? "Zap ${formatSats(_amount!)} sats" : "Zap", + _amount != null + ? t.zap.button_zap_ready(amount: formatSats(_amount!)) + : t.zap.button_zap, disabled: _amount == null, decoration: BoxDecoration(color: LAYER_3, borderRadius: DEFAULT_BR), onTap: () async { @@ -179,7 +175,7 @@ class _ZapWidget extends State { if (Platform.isIOS && context.mounted) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text("Copied to clipboard"))); + ).showSnackBar(SnackBar(content: Text(t.zap.copy))); } }, child: Container( @@ -195,7 +191,7 @@ class _ZapWidget extends State { ), ), BasicButton.text( - "Open in Wallet", + t.zap.button_open_wallet, onTap: () async { try { await launchUrlString(prLink); @@ -203,7 +199,7 @@ class _ZapWidget extends State { if (e is PlatformException) { if (e.code == "ACTIVITY_NOT_FOUND") { setState(() { - _error = "No lightning wallet installed"; + _error = t.zap.error.no_wallet; }); return; } @@ -266,7 +262,7 @@ class _ZapWidget extends State { Future _loadZap() async { final profile = await ndk.metadata.loadMetadata(widget.pubkey); if (profile?.lud16 == null) { - throw "No lightning address found"; + throw t.zap.error.no_lud16; } final zapRequest = await _makeZap(); diff --git a/pubspec.lock b/pubspec.lock index a745b65..4c04861 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + csv: + dependency: transitive + description: + name: csv + sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c + url: "https://pub.dev" + source: hosted + version: "6.0.0" cupertino_icons: dependency: transitive description: @@ -302,6 +310,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_markdown_plus: dependency: "direct main" description: @@ -396,10 +409,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "2b9ba6d4c247457c35a6622f1dee6aab6694a4e15237ff7c32320345044112b6" + sha256: "0b1e06223bee260dee31a171fb1153e306907563a0b0225e8c1733211911429a" url: "https://pub.dev" source: hosted - version: "15.1.1" + version: "15.1.2" hex: dependency: transitive description: @@ -500,10 +513,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.19.0" js: dependency: transitive description: @@ -896,6 +909,24 @@ packages: description: flutter source: sdk version: "0.0.0" + slang: + dependency: "direct main" + description: + path: slang + ref: f6dbb7ec212b135074e6b5eb5bef9ddf6afb75d1 + resolved-ref: f6dbb7ec212b135074e6b5eb5bef9ddf6afb75d1 + url: "https://github.com/nostrlabs-io/slang" + source: git + version: "4.7.1" + slang_flutter: + dependency: "direct main" + description: + path: slang_flutter + ref: f6dbb7ec212b135074e6b5eb5bef9ddf6afb75d1 + resolved-ref: f6dbb7ec212b135074e6b5eb5bef9ddf6afb75d1 + url: "https://github.com/nostrlabs-io/slang" + source: git + version: "4.7.0" source_span: dependency: transitive description: @@ -1132,10 +1163,10 @@ packages: dependency: transitive description: name: video_player_android - sha256: "1f4e8e0e02403452d699ef7cf73fe9936fac8f6f0605303d111d71acb375d1bc" + sha256: "28dcc4122079f40f93a0965b3679aff1a5f4251cf79611bd8011f937eb6b69de" url: "https://pub.dev" source: hosted - version: "2.8.3" + version: "2.8.4" video_player_avfoundation: dependency: transitive description: @@ -1184,6 +1215,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" web: dependency: transitive description: @@ -1220,10 +1259,10 @@ packages: dependency: transitive description: name: win32 - sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" url: "https://pub.dev" source: hosted - version: "5.12.0" + version: "5.13.0" xdg_directories: dependency: transitive description: @@ -1248,6 +1287,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.7.2 <4.0.0" flutter: ">=3.27.1" diff --git a/pubspec.yaml b/pubspec.yaml index ace07f5..5440b8b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: zap_stream_flutter description: "zap.stream" -publish_to: 'none' +publish_to: "none" version: 0.7.0+9 environment: @@ -31,10 +31,14 @@ dependencies: image_picker: ^1.1.2 emoji_picker_flutter: ^4.3.0 bech32: ^0.2.2 - intl: ^0.20.2 flutter_markdown_plus: ^1.0.3 share_plus: ^11.0.0 duration: ^4.0.3 + slang: ^4.7.1 + slang_flutter: ^4.7.0 + intl: ^0.19.0 + flutter_localizations: + sdk: flutter dependency_overrides: ndk: @@ -52,6 +56,16 @@ dependency_overrides: url: https://github.com/relaystr/ndk path: packages/amber ref: 919f35866f4b9d84565f7f08ebbbcd5fd0ef0b6a + slang: + git: + url: https://github.com/nostrlabs-io/slang + path: slang + ref: f6dbb7ec212b135074e6b5eb5bef9ddf6afb75d1 + slang_flutter: + git: + url: https://github.com/nostrlabs-io/slang + path: slang_flutter + ref: f6dbb7ec212b135074e6b5eb5bef9ddf6afb75d1 dev_dependencies: flutter_test: @@ -63,4 +77,4 @@ flutter: assets: - "assets/svg/" - "assets/logo.png" - - "assets/category/" \ No newline at end of file + - "assets/category/"